mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
feat(oauth): implement OAuth page rendering and enhance authentication flow
This commit is contained in:
parent
c4e35ac21c
commit
eb76c02d43
5 changed files with 191 additions and 21 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
||||
|
|
|
|||
72
surfsense_desktop/src/modules/oauth-page.ts
Normal file
72
surfsense_desktop/src/modules/oauth-page.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import http from 'node:http';
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue