From 3a83d72812ceab60ada72a7c27009abb3f39448f Mon Sep 17 00:00:00 2001 From: hsingh Date: Wed, 18 Jun 2025 15:58:05 +0530 Subject: [PATCH] chore: PKCE based login flow --- apps/electron/src/main/deep-link.ts | 61 ++++++++++++++ apps/electron/src/main/main.ts | 13 +++ apps/electron/src/main/oauth/flow.ts | 107 ++++++++++++++++++++++++ apps/electron/src/main/oauth/pkce.ts | 9 ++ apps/electron/src/main/preload.ts | 13 +++ apps/electron/src/renderer/renderer.tsx | 37 +++++++- apps/electron/src/types/electron-api.ts | 5 ++ 7 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 apps/electron/src/main/deep-link.ts create mode 100644 apps/electron/src/main/oauth/flow.ts create mode 100644 apps/electron/src/main/oauth/pkce.ts diff --git a/apps/electron/src/main/deep-link.ts b/apps/electron/src/main/deep-link.ts new file mode 100644 index 0000000..a1cebe6 --- /dev/null +++ b/apps/electron/src/main/deep-link.ts @@ -0,0 +1,61 @@ +import { app } from 'electron'; +import path from 'node:path'; + +/** + * Registers single-instance enforcement and OS-level handlers for the custom + * amical:// URL scheme. All detected URLs are forwarded to the supplied + * callback. The module also buffers early links that arrive before + * `app.whenReady()` so callers never miss the first deep-link. + */ +export function registerDeepLinkHandlers(handle: (url: string) => void): void { + let pending: string | null = null; + + // Obtain the single-instance lock; quit if we lose. + const gotLock = app.requestSingleInstanceLock(); + if (!gotLock) { + app.quit(); + return; + } + + // Claim the custom protocol so the OS routes amical:// URLs to us. + if (app.isPackaged) { + // In a packaged build the bundle itself owns the scheme. + app.setAsDefaultProtocolClient('amical'); + } else { + // During development we must tell the OS how to start Electron **and** + // which entry JS file to run; otherwise macOS launches the bare framework + // application that shows the default Electron splash screen. + const exe = process.execPath; // e.g. node_modules/.bin/electron + const entry = path.resolve(process.argv[1]); // your dev main entry + // The leading '--' tells Electron that the next argument is the app path. + app.setAsDefaultProtocolClient('amical', exe, ['--', entry]); + } + + // Windows/Linux: second instance forwards argv containing the deep link. + app.on('second-instance', (_e, argv) => { + const link = argv.find((a) => a.startsWith('amical://')); + if (!link) return; + if (app.isReady()) handle(link); + else pending = link; + }); + + // macOS: dedicated open-url event (can fire before ready). + app.on('open-url', (event, url) => { + event.preventDefault(); + if (app.isReady()) handle(url); + else pending = url; + }); + + // Once Electron is ready flush any buffered links and check argv on Windows. + app.whenReady().then(() => { + if (pending) { + handle(pending); + pending = null; + } + + if (process.platform === 'win32') { + const firstLink = process.argv.find((a) => a.startsWith('amical://')); + if (firstLink) handle(firstLink); + } + }); +} \ No newline at end of file diff --git a/apps/electron/src/main/main.ts b/apps/electron/src/main/main.ts index 28e0c64..33d5be1 100644 --- a/apps/electron/src/main/main.ts +++ b/apps/electron/src/main/main.ts @@ -14,6 +14,7 @@ import started from 'electron-squirrel-startup'; import Store from 'electron-store'; //import { runMigrations } from '../db/migrate'; import { HelperEvent, KeyEventPayload } from '@amical/types'; +//import crypto from 'node:crypto'; // For PKCE helpers dotenv.config(); // Load .env file import { AudioCapture } from '../modules/audio/audio-capture'; @@ -21,6 +22,8 @@ import { setupApplicationMenu } from './menu'; import { OpenAIWhisperClient } from '../modules/ai/openai-whisper-client'; import { AiService } from '../modules/ai/ai-service'; import { SwiftIOBridge } from './swift-io-bridge'; // Added import +import { registerDeepLinkHandlers } from './deep-link'; +import { initOAuth } from './oauth/flow'; // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) { @@ -41,12 +44,22 @@ let openAiApiKey: string | null = null; let currentWindowDisplayId: number | null = null; // For tracking current display let activeSpaceChangeSubscriptionId: number | null = null; // For display change notifications +// (pendingDeepLink logic moved to deep-link.ts) + +// OAuth logic has been moved to ./oauth/flow.ts + interface StoreSchema { 'openai-api-key': string; } const store = new Store(); +// Initialise the modular OAuth handler and receive the deep-link callback. +const handleDeepLink = initOAuth(store, () => mainWindow); + +// Register OS-level deep-link/instance handlers. +registerDeepLinkHandlers(handleDeepLink); + ipcMain.handle('set-api-key', (event, apiKey: string) => { console.log('Main: Received set-api-key', event, ' API key:', apiKey); openAiApiKey = apiKey; diff --git a/apps/electron/src/main/oauth/flow.ts b/apps/electron/src/main/oauth/flow.ts new file mode 100644 index 0000000..d03f5b9 --- /dev/null +++ b/apps/electron/src/main/oauth/flow.ts @@ -0,0 +1,107 @@ +import { ipcMain, shell, BrowserWindow } from 'electron'; +import Store from 'electron-store'; +import { randomBase64url, sha256base64url } from './pkce'; + +interface PendingRequest { + verifier: string; + created: number; +} + +/** + * Initialises the OAuth PKCE flow IPC handler and returns a function that + * processes deep-link callbacks (e.g. amical://oauth/callback?...). + * + * @param store electron-store instance used for token persistence + * @param getMainWindow lazy getter to obtain the current BrowserWindow (can be null) + * @returns handleDeepLink(url) + */ +export function initOAuth( + store: Store, + getMainWindow: () => BrowserWindow | null +): (url: string) => void { + const pending = new Map(); + + // House-keeping timer: prune entries older than 5 minutes. + setInterval(() => { + const now = Date.now(); + for (const [state, { created }] of pending) { + if (now - created > 5 * 60_000) pending.delete(state); + } + }, 60_000); + + ipcMain.handle('oauth-login', async () => { + const clientId = process.env.OAUTH_CLIENT_ID; + if (!clientId) throw new Error('OAUTH_CLIENT_ID env var not defined'); + + const verifier = randomBase64url(); + const challenge = sha256base64url(verifier); + const state = randomBase64url(16); + pending.set(state, { verifier, created: Date.now() }); + + // TODO: replace with real authorize endpoint. + const authUrl = new URL(process.env.OAUTH_AUTHORIZE_URL!); + authUrl.searchParams.set('client_id', clientId); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', 'amical://oauth/callback'); + authUrl.searchParams.set('code_challenge', challenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('scope', 'openid profile'); + authUrl.searchParams.set('prompt', 'login'); + + console.log('[OAuth] opening browser to:', authUrl.toString()); + await shell.openExternal(authUrl.toString()); + }); + + async function exchangeCodeForToken(code: string, verifier: string) { + const tokenUrl = process.env.OAUTH_TOKEN_URL; + const clientId = process.env.OAUTH_CLIENT_ID; + if (!tokenUrl || !clientId) throw new Error('OAUTH_TOKEN_URL or CLIENT_ID missing'); + + const body = JSON.stringify({ + grant_type: 'authorization_code', + code, + client_id: clientId, + client_secret: "amical-client-secret", + redirect_uri: 'amical://oauth/callback', + code_verifier: verifier, + }); + + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body, + }); + if (!res.ok) { + throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`); + } + return await res.json(); + } + + async function handleDeepLink(url: string) { + try { + const u = new URL(url); + if (!(u.hostname === 'oauth' && u.pathname === '/callback')) return; // ignore others + + console.log('[OAuth] deep-link callback:', u.toString()); + const code = u.searchParams.get('code'); + const state = u.searchParams.get('state'); + if (!code || !state) throw new Error('Missing code or state'); + + const entry = pending.get(state); + if (!entry) throw new Error('State mismatch / expired'); + pending.delete(state); + + const token = await exchangeCodeForToken(code, entry.verifier); + store.set('oauth-token', token); + + getMainWindow()?.webContents.send('oauth-success', token); + console.log('[OAuth] login success'); + } catch (err) { + console.error('[OAuth] deep-link error:', err); + getMainWindow()?.webContents.send('oauth-error', (err as Error).message); + } + } + + return handleDeepLink; +} \ No newline at end of file diff --git a/apps/electron/src/main/oauth/pkce.ts b/apps/electron/src/main/oauth/pkce.ts new file mode 100644 index 0000000..e63564a --- /dev/null +++ b/apps/electron/src/main/oauth/pkce.ts @@ -0,0 +1,9 @@ +import crypto from 'node:crypto'; + +export function randomBase64url(bytes: number = 32): string { + return crypto.randomBytes(bytes).toString('base64url'); +} + +export function sha256base64url(input: string): string { + return crypto.createHash('sha256').update(input).digest('base64url'); +} \ No newline at end of file diff --git a/apps/electron/src/main/preload.ts b/apps/electron/src/main/preload.ts index 3e5b982..c043f95 100644 --- a/apps/electron/src/main/preload.ts +++ b/apps/electron/src/main/preload.ts @@ -50,6 +50,19 @@ const api: ElectronAPI = { // ipcRenderer.removeAllListeners('global-shortcut-event'); // } setApiKey: (apiKey: string) => ipcRenderer.invoke('set-api-key', apiKey), + + /* ----------------------- OAuth ------------------------- */ + oauthLogin: (): Promise => ipcRenderer.invoke('oauth-login'), + onOauthSuccess: (callback: (token: unknown) => void) => { + const handler = (_event: IpcRendererEvent, token: unknown) => callback(token); + ipcRenderer.on('oauth-success', handler); + return () => ipcRenderer.removeListener('oauth-success', handler); + }, + onOauthError: (callback: (msg: string) => void) => { + const handler = (_event: IpcRendererEvent, msg: string) => callback(msg); + ipcRenderer.on('oauth-error', handler); + return () => ipcRenderer.removeListener('oauth-error', handler); + }, }; contextBridge.exposeInMainWorld('electronAPI', api); diff --git a/apps/electron/src/renderer/renderer.tsx b/apps/electron/src/renderer/renderer.tsx index 7cee254..e29f88e 100644 --- a/apps/electron/src/renderer/renderer.tsx +++ b/apps/electron/src/renderer/renderer.tsx @@ -26,7 +26,7 @@ * ``` */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { Button } from '@/components/ui/button'; import '@/styles/globals.css'; @@ -40,6 +40,23 @@ const NUM_WAVEFORM_BARS = 10; // This might be unused now const App: React.FC = () => { const [apiKey, setApiKey] = useState(''); + const [token, setToken] = useState(null); + const [oauthError, setOauthError] = useState(null); + + // Register OAuth callbacks once + useEffect(() => { + const offSuccess = window.electronAPI.onOauthSuccess((tok) => { + setToken(tok); + setOauthError(null); + }); + const offError = window.electronAPI.onOauthError((msg) => { + setOauthError(msg); + }); + return () => { + offSuccess?.(); + offError?.(); + }; + }, []); const handleApiKeyChange = (event: React.ChangeEvent) => { setApiKey(event.target.value); @@ -50,12 +67,17 @@ const App: React.FC = () => { alert('API Key sent to main process!'); }; + const handleLogin = () => { + window.electronAPI.oauthLogin(); + }; + return (
Dictionary Configure API Key + Auth Dictionary Tab Content API Key Configuration Content @@ -73,6 +95,19 @@ const App: React.FC = () => {
+ + {token ? ( +
+

Logged in!

+
{JSON.stringify(token, null, 2)}
+
+ ) : ( +
+ + {oauthError &&

{oauthError}

} +
+ )} +
); diff --git a/apps/electron/src/types/electron-api.ts b/apps/electron/src/types/electron-api.ts index 6f19d3b..850520c 100644 --- a/apps/electron/src/types/electron-api.ts +++ b/apps/electron/src/types/electron-api.ts @@ -18,4 +18,9 @@ export interface ElectronAPI { // New method for setting the API key setApiKey: (apiKey: string) => Promise; + + // OAuth PKCE + oauthLogin: () => Promise; + onOauthSuccess: (callback: (token: unknown) => void) => (() => void) | void; + onOauthError: (callback: (msg: string) => void) => (() => void) | void; }