Skip to content

chore: PKCE based login flow #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/electron/src/main/deep-link.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
13 changes: 13 additions & 0 deletions apps/electron/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ 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';
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) {
Expand All @@ -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<StoreSchema>();

// 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;
Expand Down
107 changes: 107 additions & 0 deletions apps/electron/src/main/oauth/flow.ts
Original file line number Diff line number Diff line change
@@ -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<string, PendingRequest>();

// 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;
}
9 changes: 9 additions & 0 deletions apps/electron/src/main/oauth/pkce.ts
Original file line number Diff line number Diff line change
@@ -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');
}
13 changes: 13 additions & 0 deletions apps/electron/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ const api: ElectronAPI = {
// ipcRenderer.removeAllListeners('global-shortcut-event');
// }
setApiKey: (apiKey: string) => ipcRenderer.invoke('set-api-key', apiKey),

/* ----------------------- OAuth ------------------------- */
oauthLogin: (): Promise<void> => 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);
37 changes: 36 additions & 1 deletion apps/electron/src/renderer/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<unknown>(null);
const [oauthError, setOauthError] = useState<string | null>(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<HTMLInputElement>) => {
setApiKey(event.target.value);
Expand All @@ -50,12 +67,17 @@ const App: React.FC = () => {
alert('API Key sent to main process!');
};

const handleLogin = () => {
window.electronAPI.oauthLogin();
};

return (
<div className="flex-1 space-y-4 p-8 pt-6">
<Tabs defaultValue="dictionary" className="w-[400px]">
<TabsList>
<TabsTrigger value="dictionary">Dictionary</TabsTrigger>
<TabsTrigger value="api">Configure API Key</TabsTrigger>
<TabsTrigger value="auth">Auth</TabsTrigger>
</TabsList>
<TabsContent value="dictionary">Dictionary Tab Content</TabsContent>
<TabsContent value="api">API Key Configuration Content</TabsContent>
Expand All @@ -73,6 +95,19 @@ const App: React.FC = () => {
<Button onClick={handleSaveApiKey}>Save API Key</Button>
</div>
</TabsContent>
<TabsContent value="auth">
{token ? (
<div className="space-y-2">
<p className="text-green-600">Logged in!</p>
<pre className="bg-muted p-2 text-xs overflow-x-auto max-h-40">{JSON.stringify(token, null, 2)}</pre>
</div>
) : (
<div className="space-y-2">
<Button onClick={handleLogin}>Sign in with Provider</Button>
{oauthError && <p className="text-red-600 text-sm">{oauthError}</p>}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
Expand Down
5 changes: 5 additions & 0 deletions apps/electron/src/types/electron-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ export interface ElectronAPI {

// New method for setting the API key
setApiKey: (apiKey: string) => Promise<void>;

// OAuth PKCE
oauthLogin: () => Promise<void>;
onOauthSuccess: (callback: (token: unknown) => void) => (() => void) | void;
onOauthError: (callback: (msg: string) => void) => (() => void) | void;
}