From efca3d90a28151ffea6499d30f6989cd697ce1cc Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Thu, 5 Feb 2026 20:09:38 -0500 Subject: [PATCH 1/6] feat(auth): add OAuth types, constants, and error classes Add foundational types for OAuth authentication: - OAuthTokens, OAuthFlowStatus, AuthMethod types - Discriminated ProviderConfig union (ProviderConfigApiKey | ProviderConfigOAuth) - OAUTH_CAPABILITIES map with endpoints, scopes, PKCE params for OpenAI/Google - OAuthError and OAuthTokenExpiredError classes OpenAI OAuth aligns with official docs: offline_access scope, id_token_add_organizations, codex_cli_simplified_flow params, and token exchange grant support. Co-Authored-By: Claude Opus 4.5 --- src/shared/constants.ts | 39 ++++++++++++++++++++++++++++++++++++ src/shared/errors.ts | 12 +++++++++++ src/shared/types/index.ts | 6 ++++++ src/shared/types/provider.ts | 37 +++++++++++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f17b369..7733106 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -8,6 +8,45 @@ export const MAX_DOCUMENT_SIZE_BYTES = 1024 * 1024 // 1MB export const DEFAULT_PROVIDER = 'anthropic' as const +export const OAUTH_CAPABILITIES: Record + supportsTokenExchange?: boolean + experimental?: boolean +}> = { + openai: { + supported: true, + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', + authorizationUrl: 'https://auth.openai.com/oauth/authorize', + tokenUrl: 'https://auth.openai.com/oauth/token', + scopes: ['openid', 'profile', 'email', 'offline_access'], + extraAuthParams: { + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', + }, + supportsTokenExchange: true, + }, + anthropic: { + supported: false, + clientId: '', + authorizationUrl: '', + tokenUrl: '', + scopes: [], + }, + google: { + supported: true, + clientId: '539167010789-g3ltv0osl0j74oab94klpj41sv7l4mqb.apps.googleusercontent.com', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scopes: ['openid', 'email', 'https://www.googleapis.com/auth/generative-language'], + experimental: true, + }, +} + export const ADR_TEMPLATE = `# ADR-{number}: {title} ## Status diff --git a/src/shared/errors.ts b/src/shared/errors.ts index f0b4a31..55f95a4 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -31,3 +31,15 @@ export class QuotaExhaustedError extends KeystoneError { super(`${provider} quota exhausted`, 'QUOTA_EXHAUSTED') } } + +export class OAuthError extends KeystoneError { + constructor(provider: string, message: string) { + super(`OAuth error (${provider}): ${message}`, 'OAUTH_ERROR') + } +} + +export class OAuthTokenExpiredError extends KeystoneError { + constructor(provider: string) { + super(`OAuth token expired for ${provider}`, 'OAUTH_TOKEN_EXPIRED') + } +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 4d2410c..cd068d6 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -8,4 +8,10 @@ export type { UsageStatus, ProviderType, ProviderConfig, + ProviderConfigApiKey, + ProviderConfigOAuth, + LegacyProviderConfig, + AuthMethod, + OAuthTokens, + OAuthFlowStatus, } from './provider' diff --git a/src/shared/types/provider.ts b/src/shared/types/provider.ts index 1872f6b..37b5891 100644 --- a/src/shared/types/provider.ts +++ b/src/shared/types/provider.ts @@ -19,7 +19,42 @@ export interface UsageStatus { export type ProviderType = 'openai' | 'anthropic' | 'google' -export interface ProviderConfig { +export type AuthMethod = 'apiKey' | 'oauth' + +export interface OAuthTokens { + accessToken: string // For OpenAI: this is the exchanged API key + refreshToken?: string + idToken?: string // Original ID token, used for OpenAI token exchange + expiresAt: number // Unix timestamp in ms + accountId?: string // e.g. OpenAI chatgpt-account-id + email?: string +} + +export type OAuthFlowStatus = + | { state: 'idle' } + | { state: 'pending'; provider: ProviderType } + | { state: 'success'; provider: ProviderType; email?: string } + | { state: 'error'; provider: ProviderType; error: string } + +export interface ProviderConfigApiKey { + type: ProviderType + authMethod: 'apiKey' + apiKey: string + model?: string +} + +export interface ProviderConfigOAuth { + type: ProviderType + authMethod: 'oauth' + oauthToken: string + accountId?: string + model?: string +} + +export type ProviderConfig = ProviderConfigApiKey | ProviderConfigOAuth + +// Backward-compatible: accept legacy shape too +export interface LegacyProviderConfig { type: ProviderType apiKey: string model?: string From 94974ebf81eb2b37cca7cb32ec867a999711a54d Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Thu, 5 Feb 2026 20:10:06 -0500 Subject: [PATCH 2/6] feat(auth): add OAuth service with PKCE flow and token exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New OAuthService handles complete OAuth authentication: - PKCE flow with loopback HTTP server on random port - S256 code challenge method per RFC 7636 - OpenAI token exchange (ID token → API key via token-exchange grant) - Automatic token refresh scheduling (5 min before expiry) - XSS-safe callback HTML with proper escaping SettingsService extended with OAuth token CRUD: - getOAuthTokens, setOAuthTokens, deleteOAuthTokens - getAuthMethod, setAuthMethod - Encrypted storage via safeStorage - Backward-compatible with existing settings files Co-Authored-By: Claude Opus 4.5 --- src/main/services/OAuthService.ts | 385 +++++++++++++++++++++++++++ src/main/services/SettingsService.ts | 68 ++++- 2 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 src/main/services/OAuthService.ts diff --git a/src/main/services/OAuthService.ts b/src/main/services/OAuthService.ts new file mode 100644 index 0000000..fbf60ba --- /dev/null +++ b/src/main/services/OAuthService.ts @@ -0,0 +1,385 @@ +import * as http from 'http' +import * as crypto from 'crypto' +import { BrowserWindow, shell } from 'electron' +import { OAUTH_CAPABILITIES } from '@shared/constants' +import type { ProviderType, OAuthTokens, OAuthFlowStatus } from '@shared/types/provider' +import { OAuthError } from '@shared/errors' + +type StatusCallback = (status: OAuthFlowStatus) => void + +export class OAuthService { + private refreshTimers: Map = new Map() + private activeServer: http.Server | null = null + private statusCallback: StatusCallback | null = null + private tokenRefreshCallback: ((provider: ProviderType, tokens: OAuthTokens) => void) | null = null + + onStatus(callback: StatusCallback): void { + this.statusCallback = callback + } + + onTokenRefresh(callback: (provider: ProviderType, tokens: OAuthTokens) => void): void { + this.tokenRefreshCallback = callback + } + + private emitStatus(status: OAuthFlowStatus): void { + this.statusCallback?.(status) + const win = BrowserWindow.getAllWindows()[0] + if (win && !win.isDestroyed()) { + win.webContents.send('oauth:status', status) + } + } + + async startFlow(provider: ProviderType): Promise { + const capabilities = OAUTH_CAPABILITIES[provider] + if (!capabilities?.supported) { + throw new OAuthError(provider, 'OAuth not supported for this provider') + } + + // Cancel any existing flow + this.cancelFlow() + + this.emitStatus({ state: 'pending', provider }) + + // Generate PKCE pair + const codeVerifier = crypto.randomBytes(32).toString('base64url') + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url') + + const state = crypto.randomBytes(16).toString('hex') + + return new Promise((resolve, reject) => { + let redirectUri = '' + let settled = false + + const settle = (fn: () => void) => { + if (!settled) { + settled = true + fn() + } + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1`) + + if (url.pathname !== '/callback') { + res.writeHead(404) + res.end('Not found') + return + } + + const code = url.searchParams.get('code') + const returnedState = url.searchParams.get('state') + const error = url.searchParams.get('error') + + if (error) { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(this.buildCallbackHtml(false, `Authorization denied: ${error}`)) + this.emitStatus({ state: 'error', provider, error: `Authorization denied: ${error}` }) + this.shutdownServer() + settle(() => reject(new OAuthError(provider, `Authorization denied: ${error}`))) + return + } + + if (!code || returnedState !== state) { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(this.buildCallbackHtml(false, 'Invalid callback parameters')) + this.emitStatus({ state: 'error', provider, error: 'Invalid callback parameters' }) + this.shutdownServer() + settle(() => reject(new OAuthError(provider, 'Invalid callback parameters'))) + return + } + + try { + const tokens = await this.exchangeCode(provider, code, codeVerifier, redirectUri) + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(this.buildCallbackHtml(true, 'You can close this tab and return to Keystone.')) + this.emitStatus({ state: 'success', provider, email: tokens.email }) + this.shutdownServer() + settle(() => resolve(tokens)) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Token exchange failed' + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(this.buildCallbackHtml(false, msg)) + this.emitStatus({ state: 'error', provider, error: msg }) + this.shutdownServer() + settle(() => reject(new OAuthError(provider, msg))) + } + }) + + // Listen on random port on loopback + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (!addr || typeof addr === 'string') { + settle(() => reject(new OAuthError(provider, 'Failed to start loopback server'))) + return + } + + this.activeServer = server + redirectUri = `http://127.0.0.1:${addr.port}/callback` + + // Build authorization URL + const params = new URLSearchParams({ + client_id: capabilities.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: capabilities.scopes.join(' '), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + ...capabilities.extraAuthParams, + }) + + const authUrl = `${capabilities.authorizationUrl}?${params.toString()}` + shell.openExternal(authUrl) + }) + + // Timeout after 5 minutes + setTimeout(() => { + if (this.activeServer === server) { + this.emitStatus({ state: 'error', provider, error: 'Authorization timed out' }) + this.shutdownServer() + settle(() => reject(new OAuthError(provider, 'Authorization timed out'))) + } + }, 5 * 60 * 1000) + }) + } + + private async exchangeCode( + provider: ProviderType, + code: string, + codeVerifier: string, + redirectUri: string, + ): Promise { + const capabilities = OAUTH_CAPABILITIES[provider] + if (!capabilities) throw new OAuthError(provider, 'Unknown provider') + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: capabilities.clientId, + code_verifier: codeVerifier, + }) + + const response = await fetch(capabilities.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!response.ok) { + const text = await response.text() + throw new OAuthError(provider, `Token exchange failed: ${response.status} ${text}`) + } + + const data = await response.json() + + const tokens: OAuthTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000, + } + + // Store and parse ID token if present + if (data.id_token) { + tokens.idToken = data.id_token + try { + const payload = JSON.parse( + Buffer.from(data.id_token.split('.')[1], 'base64url').toString(), + ) + if (payload.email) tokens.email = payload.email + } catch { + // ID token parsing is best-effort + } + } + + // OpenAI-specific: extract account ID + if (provider === 'openai' && data.account_id) { + tokens.accountId = data.account_id + } + + // OpenAI token exchange: convert ID token to API key + if (provider === 'openai' && capabilities.supportsTokenExchange && data.id_token) { + const apiKey = await this.exchangeForApiKey(capabilities, data.id_token) + if (apiKey) { + tokens.accessToken = apiKey + } else { + throw new OAuthError(provider, 'Failed to exchange token for API key. Please try again.') + } + } + + return tokens + } + + private async exchangeForApiKey( + capabilities: { tokenUrl: string; clientId: string }, + idToken: string, + ): Promise { + try { + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + client_id: capabilities.clientId, + requested_token: 'openai-api-key', + subject_token: idToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + }) + + const response = await fetch(capabilities.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!response.ok) { + console.error('Token exchange for API key failed:', response.status) + return null + } + + const data = await response.json() + return data.access_token || null + } catch (error) { + console.error('Token exchange error:', error) + return null + } + } + + async refreshToken(provider: ProviderType, currentTokens: OAuthTokens): Promise { + if (!currentTokens.refreshToken) return null + + const capabilities = OAUTH_CAPABILITIES[provider] + if (!capabilities?.supported) return null + + try { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: currentTokens.refreshToken, + client_id: capabilities.clientId, + }) + + const response = await fetch(capabilities.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!response.ok) { + console.error(`Token refresh failed for ${provider}: ${response.status}`) + return null + } + + const data = await response.json() + + const newTokens: OAuthTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token || currentTokens.refreshToken, + idToken: data.id_token || currentTokens.idToken, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000, + accountId: currentTokens.accountId, + email: currentTokens.email, + } + + // OpenAI: re-exchange the new ID token for an API key + if (provider === 'openai' && capabilities.supportsTokenExchange && newTokens.idToken) { + const apiKey = await this.exchangeForApiKey(capabilities, newTokens.idToken) + if (apiKey) { + newTokens.accessToken = apiKey + } + } + + return newTokens + } catch (error) { + console.error(`Token refresh error for ${provider}:`, error) + return null + } + } + + scheduleRefresh(provider: ProviderType, tokens: OAuthTokens): void { + this.clearRefreshTimer(provider) + + if (!tokens.refreshToken) return + + const refreshAt = tokens.expiresAt - 5 * 60 * 1000 + const delay = Math.max(refreshAt - Date.now(), 10_000) + + const timer = setTimeout(async () => { + const newTokens = await this.refreshToken(provider, tokens) + if (newTokens) { + this.tokenRefreshCallback?.(provider, newTokens) + this.scheduleRefresh(provider, newTokens) + } else { + this.emitStatus({ state: 'error', provider, error: 'Token refresh failed. Please sign in again.' }) + } + }, delay) + + this.refreshTimers.set(provider, timer) + } + + clearRefreshTimer(provider: string): void { + const timer = this.refreshTimers.get(provider) + if (timer) { + clearTimeout(timer) + this.refreshTimers.delete(provider) + } + } + + cancelFlow(): void { + this.shutdownServer() + } + + private shutdownServer(): void { + if (this.activeServer) { + this.activeServer.close() + this.activeServer = null + } + } + + getCapabilities(provider: ProviderType): { supported: boolean; experimental?: boolean } { + const cap = OAUTH_CAPABILITIES[provider] + return { + supported: cap?.supported ?? false, + experimental: cap?.experimental, + } + } + + destroy(): void { + this.shutdownServer() + for (const timer of this.refreshTimers.values()) { + clearTimeout(timer) + } + this.refreshTimers.clear() + } + + private escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + private buildCallbackHtml(success: boolean, message: string): string { + return ` + + + Keystone - OAuth + + + +
+
${success ? '✓' : '✗'}
+

${success ? 'Connected!' : 'Connection Failed'}

+

${this.escapeHtml(message)}

+
+ +` + } +} diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 8cbb6bf..8b3a723 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -5,6 +5,8 @@ import * as path from 'path' interface SettingsData { activeProvider: string | null apiKeys: Record // stored as base64-encoded encrypted strings + oauthTokens: Record // stored as base64-encoded encrypted JSON strings + authMethods: Record // 'apiKey' | 'oauth' } export class SettingsService { @@ -21,6 +23,9 @@ export class SettingsService { } this.settings = this.loadSettings() + // Migrate older settings files that lack OAuth fields + if (!this.settings.oauthTokens) this.settings.oauthTokens = {} + if (!this.settings.authMethods) this.settings.authMethods = {} } private loadSettings(): SettingsData { @@ -35,7 +40,9 @@ export class SettingsService { return { activeProvider: null, - apiKeys: {} + apiKeys: {}, + oauthTokens: {}, + authMethods: {}, } } @@ -101,10 +108,67 @@ export class SettingsService { } getConfiguredProviders(): string[] { - return Object.keys(this.settings.apiKeys) + return [...new Set([ + ...Object.keys(this.settings.apiKeys), + ...Object.keys(this.settings.oauthTokens), + ])] } hasApiKey(provider: string): boolean { return !!this.settings.apiKeys[provider] } + + getOAuthTokens(provider: string): import('@shared/types/provider').OAuthTokens | null { + const encrypted = this.settings.oauthTokens[provider] + if (!encrypted) return null + + try { + let json: string + if (this.encryptionAvailable) { + const buffer = Buffer.from(encrypted, 'base64') + json = safeStorage.decryptString(buffer) + } else { + json = encrypted + } + return JSON.parse(json) + } catch (error) { + console.error(`Failed to decrypt OAuth tokens for ${provider}:`, error) + return null + } + } + + setOAuthTokens(provider: string, tokens: import('@shared/types/provider').OAuthTokens): void { + try { + const json = JSON.stringify(tokens) + if (this.encryptionAvailable) { + const encrypted = safeStorage.encryptString(json) + this.settings.oauthTokens[provider] = encrypted.toString('base64') + } else { + this.settings.oauthTokens[provider] = json + } + this.saveSettings() + } catch (error) { + console.error(`Failed to encrypt and save OAuth tokens for ${provider}:`, error) + throw error + } + } + + deleteOAuthTokens(provider: string): void { + delete this.settings.oauthTokens[provider] + delete this.settings.authMethods[provider] + this.saveSettings() + } + + getAuthMethod(provider: string): 'apiKey' | 'oauth' { + return (this.settings.authMethods[provider] as 'apiKey' | 'oauth') || 'apiKey' + } + + setAuthMethod(provider: string, method: 'apiKey' | 'oauth'): void { + this.settings.authMethods[provider] = method + this.saveSettings() + } + + getOAuthConfiguredProviders(): string[] { + return Object.keys(this.settings.oauthTokens) + } } From 0c0da2f05a3aa6b77b3637fd580d08040671bcf0 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Thu, 5 Feb 2026 20:10:28 -0500 Subject: [PATCH 3/6] feat(auth): update adapters and IPC for OAuth authentication Adapters support dual authentication: - OpenAIAdapter: API key OR OAuth token with chatgpt-account-id header - GoogleAdapter: API key (x-goog-api-key) OR OAuth Bearer token - ProviderManager: handles discriminated config union, updateOAuthToken() New oauth.router with tRPC procedures: - startFlow, disconnect, getStatus, getCapabilities IPC layer updates: - Context instantiates OAuthService, restores OAuth on startup - Expired tokens refreshed immediately before provider configuration - Token refresh callback wired for automatic updates - Preload bridge exposes onOAuthStatus/removeOAuthListeners Co-Authored-By: Claude Opus 4.5 --- src/agents/providers/GoogleAdapter.ts | 38 +++++++++++-- src/agents/providers/OpenAIAdapter.ts | 43 +++++++++++--- src/agents/providers/ProviderManager.ts | 46 +++++++++++++-- src/main/ipc/ai.router.ts | 25 +++++++-- src/main/ipc/context.ts | 59 ++++++++++++++++++-- src/main/ipc/oauth.router.ts | 74 +++++++++++++++++++++++++ src/main/ipc/router.ts | 2 + src/main/ipc/trpc.ts | 2 + src/preload/index.d.ts | 2 + src/preload/index.ts | 9 +++ src/renderer/env.d.ts | 2 + 11 files changed, 272 insertions(+), 30 deletions(-) create mode 100644 src/main/ipc/oauth.router.ts diff --git a/src/agents/providers/GoogleAdapter.ts b/src/agents/providers/GoogleAdapter.ts index 4cfe3e6..77acb70 100644 --- a/src/agents/providers/GoogleAdapter.ts +++ b/src/agents/providers/GoogleAdapter.ts @@ -1,13 +1,41 @@ import { BaseLLMClient } from './BaseLLMClient' import type { ChatMessage, ChatOptions } from '@shared/types/provider' +interface GoogleAuthApiKey { + apiKey: string +} + +interface GoogleAuthOAuth { + oauthToken: string +} + +type GoogleAuth = GoogleAuthApiKey | GoogleAuthOAuth + export class GoogleAdapter extends BaseLLMClient { - private apiKey: string + private auth: GoogleAuth private baseUrl = 'https://generativelanguage.googleapis.com/v1beta' - constructor(apiKey: string) { + constructor(auth: string | GoogleAuth) { super() - this.apiKey = apiKey + this.auth = typeof auth === 'string' ? { apiKey: auth } : auth + } + + updateOAuthToken(token: string): void { + this.auth = { oauthToken: token } + } + + private getAuthHeaders(): Record { + if ('oauthToken' in this.auth) { + return { Authorization: `Bearer ${this.auth.oauthToken}` } + } + return { 'x-goog-api-key': this.auth.apiKey } + } + + private getUrl(model: string): string { + const base = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse` + // When using API key (not OAuth), append key as query param is not needed + // since we send it via header. Just return base URL. + return base } async *chat(messages: ChatMessage[], options?: ChatOptions): AsyncIterable { @@ -35,13 +63,13 @@ export class GoogleAdapter extends BaseLLMClient { } } - const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse` + const url = this.getUrl(model) const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-goog-api-key': this.apiKey, + ...this.getAuthHeaders(), }, body: JSON.stringify(body), }) diff --git a/src/agents/providers/OpenAIAdapter.ts b/src/agents/providers/OpenAIAdapter.ts index 47703f7..2470ca6 100644 --- a/src/agents/providers/OpenAIAdapter.ts +++ b/src/agents/providers/OpenAIAdapter.ts @@ -1,13 +1,45 @@ import { BaseLLMClient } from './BaseLLMClient' import type { ChatMessage, ChatOptions } from '@shared/types/provider' +interface OpenAIAuthApiKey { + apiKey: string +} + +interface OpenAIAuthOAuth { + oauthToken: string + accountId?: string +} + +type OpenAIAuth = OpenAIAuthApiKey | OpenAIAuthOAuth + export class OpenAIAdapter extends BaseLLMClient { - private apiKey: string + private auth: OpenAIAuth private baseUrl = 'https://api.openai.com/v1' - constructor(apiKey: string) { + constructor(auth: string | OpenAIAuth) { super() - this.apiKey = apiKey + this.auth = typeof auth === 'string' ? { apiKey: auth } : auth + } + + updateOAuthToken(token: string, accountId?: string): void { + this.auth = { oauthToken: token, accountId } + } + + private getHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if ('oauthToken' in this.auth) { + headers['Authorization'] = `Bearer ${this.auth.oauthToken}` + if (this.auth.accountId) { + headers['chatgpt-account-id'] = this.auth.accountId + } + } else { + headers['Authorization'] = `Bearer ${this.auth.apiKey}` + } + + return headers } async *chat(messages: ChatMessage[], options?: ChatOptions): AsyncIterable { @@ -31,10 +63,7 @@ export class OpenAIAdapter extends BaseLLMClient { const response = await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, - }, + headers: this.getHeaders(), body: JSON.stringify(body), }) diff --git a/src/agents/providers/ProviderManager.ts b/src/agents/providers/ProviderManager.ts index 5cf862e..0c519fb 100644 --- a/src/agents/providers/ProviderManager.ts +++ b/src/agents/providers/ProviderManager.ts @@ -1,4 +1,4 @@ -import type { ProviderType, ProviderConfig } from '@shared/types/provider' +import type { ProviderType, ProviderConfig, LegacyProviderConfig } from '@shared/types/provider' import { BaseLLMClient } from './BaseLLMClient' import { AnthropicAdapter } from './AnthropicAdapter' import { OpenAIAdapter } from './OpenAIAdapter' @@ -8,21 +8,36 @@ export class ProviderManager { private providers: Map = new Map() private activeProvider: ProviderType | null = null - configure(config: ProviderConfig): void { + configure(config: ProviderConfig | LegacyProviderConfig): void { let client: BaseLLMClient + // Normalize: legacy configs lack authMethod field + const authMethod = 'authMethod' in config ? config.authMethod : 'apiKey' + switch (config.type) { case 'anthropic': - client = new AnthropicAdapter(config.apiKey) + // Anthropic only supports API keys + if (authMethod === 'oauth') { + throw new Error('Anthropic does not support OAuth authentication') + } + client = new AnthropicAdapter('apiKey' in config ? config.apiKey : '') break case 'openai': - client = new OpenAIAdapter(config.apiKey) + if (authMethod === 'oauth' && 'oauthToken' in config) { + client = new OpenAIAdapter({ oauthToken: config.oauthToken, accountId: config.accountId }) + } else { + client = new OpenAIAdapter('apiKey' in config ? config.apiKey : '') + } break case 'google': - client = new GoogleAdapter(config.apiKey) + if (authMethod === 'oauth' && 'oauthToken' in config) { + client = new GoogleAdapter({ oauthToken: config.oauthToken }) + } else { + client = new GoogleAdapter('apiKey' in config ? config.apiKey : '') + } break default: - throw new Error(`Unknown provider: ${config.type}`) + throw new Error(`Unknown provider: ${(config as any).type}`) } this.providers.set(config.type, client) @@ -31,6 +46,17 @@ export class ProviderManager { } } + updateOAuthToken(provider: ProviderType, token: string, accountId?: string): void { + const client = this.providers.get(provider) + if (!client) return + + if (provider === 'openai' && client instanceof OpenAIAdapter) { + client.updateOAuthToken(token, accountId) + } else if (provider === 'google' && client instanceof GoogleAdapter) { + client.updateOAuthToken(token) + } + } + setActive(type: ProviderType): void { if (!this.providers.has(type)) { throw new Error(`Provider ${type} not configured`) @@ -60,4 +86,12 @@ export class ProviderManager { getConfiguredProviders(): ProviderType[] { return Array.from(this.providers.keys()) } + + removeProvider(type: ProviderType): void { + this.providers.delete(type) + if (this.activeProvider === type) { + const remaining = Array.from(this.providers.keys()) + this.activeProvider = remaining.length > 0 ? remaining[0] : null + } + } } diff --git a/src/main/ipc/ai.router.ts b/src/main/ipc/ai.router.ts index cc3641e..defcd9d 100644 --- a/src/main/ipc/ai.router.ts +++ b/src/main/ipc/ai.router.ts @@ -144,16 +144,29 @@ export const aiRouter = router({ .input( z.object({ type: z.enum(['openai', 'anthropic', 'google']), - apiKey: z.string().min(1), + apiKey: z.string().min(1).optional(), + authMethod: z.enum(['apiKey', 'oauth']).optional(), + oauthToken: z.string().optional(), + accountId: z.string().optional(), }), ) .mutation(async ({ input, ctx }) => { - // Configure the in-memory provider - ctx.providerManager.configure({ type: input.type, apiKey: input.apiKey }) - ctx.providerManager.setActive(input.type) + const authMethod = input.authMethod || 'apiKey' + + if (authMethod === 'oauth' && input.oauthToken) { + ctx.providerManager.configure({ + type: input.type, + authMethod: 'oauth', + oauthToken: input.oauthToken, + accountId: input.accountId, + }) + } else if (input.apiKey) { + ctx.providerManager.configure({ type: input.type, authMethod: 'apiKey', apiKey: input.apiKey }) + ctx.settingsService.setApiKey(input.type, input.apiKey) + ctx.settingsService.setAuthMethod(input.type, 'apiKey') + } - // Persist the API key and active provider selection - ctx.settingsService.setApiKey(input.type, input.apiKey) + ctx.providerManager.setActive(input.type) ctx.settingsService.setActiveProvider(input.type) return { diff --git a/src/main/ipc/context.ts b/src/main/ipc/context.ts index 47deda9..73acf90 100644 --- a/src/main/ipc/context.ts +++ b/src/main/ipc/context.ts @@ -1,4 +1,5 @@ import type { Context } from './trpc' +import type { ProviderType } from '@shared/types/provider' import { DatabaseService } from '../services/DatabaseService' import { ProjectService } from '../services/ProjectService' import { DocumentService } from '../services/DocumentService' @@ -6,6 +7,7 @@ import { ThreadService } from '../services/ThreadService' import { ProviderManager } from '../../agents/providers/ProviderManager' import { Orchestrator } from '../../agents/orchestrator/Orchestrator' import { SettingsService } from '../services/SettingsService' +import { OAuthService } from '../services/OAuthService' let _context: Context | null = null @@ -20,13 +22,58 @@ export async function createContext(): Promise { const settingsService = new SettingsService() const providerManager = new ProviderManager() const orchestrator = new Orchestrator() + const oauthService = new OAuthService() - // Restore any previously configured providers from persisted settings + // Wire OAuth token refresh callback + oauthService.onTokenRefresh((provider, tokens) => { + settingsService.setOAuthTokens(provider, tokens) + providerManager.updateOAuthToken(provider, tokens.accessToken, tokens.accountId) + }) + + // Restore API-key-based providers from persisted settings for (const provider of settingsService.getConfiguredProviders()) { - const apiKey = settingsService.getApiKey(provider) - if (apiKey) { + const authMethod = settingsService.getAuthMethod(provider) + if (authMethod === 'apiKey') { + const apiKey = settingsService.getApiKey(provider) + if (apiKey) { + try { + providerManager.configure({ type: provider as ProviderType, authMethod: 'apiKey', apiKey }) + } catch { + // Skip invalid providers silently + } + } + } + } + + // Restore OAuth-based providers from persisted settings + for (const provider of settingsService.getOAuthConfiguredProviders()) { + const tokens = settingsService.getOAuthTokens(provider) + const authMethod = settingsService.getAuthMethod(provider) + if (tokens && authMethod === 'oauth') { try { - providerManager.configure({ type: provider as 'openai' | 'anthropic' | 'google', apiKey }) + // Check if token is expired — attempt immediate refresh if so + if (tokens.expiresAt < Date.now() && tokens.refreshToken) { + const refreshed = await oauthService.refreshToken(provider as ProviderType, tokens) + if (refreshed) { + settingsService.setOAuthTokens(provider, refreshed) + providerManager.configure({ + type: provider as ProviderType, + authMethod: 'oauth', + oauthToken: refreshed.accessToken, + accountId: refreshed.accountId, + }) + oauthService.scheduleRefresh(provider as ProviderType, refreshed) + } + // If refresh fails, skip — user will need to re-authenticate + } else { + providerManager.configure({ + type: provider as ProviderType, + authMethod: 'oauth', + oauthToken: tokens.accessToken, + accountId: tokens.accountId, + }) + oauthService.scheduleRefresh(provider as ProviderType, tokens) + } } catch { // Skip invalid providers silently } @@ -37,12 +84,12 @@ export async function createContext(): Promise { const activeProvider = settingsService.getActiveProvider() if (activeProvider && providerManager.isConfigured()) { try { - providerManager.setActive(activeProvider as 'openai' | 'anthropic' | 'google') + providerManager.setActive(activeProvider as ProviderType) } catch { // Active provider may no longer be configured } } - _context = { db, projectService, documentService, threadService, settingsService, providerManager, orchestrator } + _context = { db, projectService, documentService, threadService, settingsService, providerManager, orchestrator, oauthService } return _context } diff --git a/src/main/ipc/oauth.router.ts b/src/main/ipc/oauth.router.ts new file mode 100644 index 0000000..ee90900 --- /dev/null +++ b/src/main/ipc/oauth.router.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' +import { router, publicProcedure } from './trpc' + +export const oauthRouter = router({ + startFlow: publicProcedure + .input(z.object({ + provider: z.enum(['openai', 'anthropic', 'google']), + })) + .mutation(async ({ input, ctx }) => { + const tokens = await ctx.oauthService.startFlow(input.provider) + + // Store tokens and update auth method + ctx.settingsService.setOAuthTokens(input.provider, tokens) + ctx.settingsService.setAuthMethod(input.provider, 'oauth') + + // Configure provider with OAuth token + ctx.providerManager.configure({ + type: input.provider, + authMethod: 'oauth', + oauthToken: tokens.accessToken, + accountId: tokens.accountId, + }) + ctx.providerManager.setActive(input.provider) + ctx.settingsService.setActiveProvider(input.provider) + + // Schedule token refresh + ctx.oauthService.scheduleRefresh(input.provider, tokens) + + return { + success: true, + email: tokens.email, + provider: input.provider, + } + }), + + disconnect: publicProcedure + .input(z.object({ + provider: z.enum(['openai', 'anthropic', 'google']), + })) + .mutation(({ input, ctx }) => { + // Clear tokens and refresh timer + ctx.settingsService.deleteOAuthTokens(input.provider) + ctx.oauthService.clearRefreshTimer(input.provider) + + // Remove the provider from memory + ctx.providerManager.removeProvider(input.provider) + + return { success: true } + }), + + getStatus: publicProcedure + .input(z.object({ + provider: z.enum(['openai', 'anthropic', 'google']), + })) + .query(({ input, ctx }) => { + const tokens = ctx.settingsService.getOAuthTokens(input.provider) + const authMethod = ctx.settingsService.getAuthMethod(input.provider) + + return { + connected: authMethod === 'oauth' && tokens !== null, + email: tokens?.email ?? null, + expiresAt: tokens?.expiresAt ?? null, + authMethod, + } + }), + + getCapabilities: publicProcedure + .input(z.object({ + provider: z.enum(['openai', 'anthropic', 'google']), + })) + .query(({ input, ctx }) => { + return ctx.oauthService.getCapabilities(input.provider) + }), +}) diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts index 38c5342..3bd60ce 100644 --- a/src/main/ipc/router.ts +++ b/src/main/ipc/router.ts @@ -4,6 +4,7 @@ import { documentRouter } from './document.router' import { threadRouter } from './thread.router' import { aiRouter } from './ai.router' import { settingsRouter } from './settings.router' +import { oauthRouter } from './oauth.router' export const appRouter = router({ project: projectRouter, @@ -11,6 +12,7 @@ export const appRouter = router({ thread: threadRouter, ai: aiRouter, settings: settingsRouter, + oauth: oauthRouter, }) export type AppRouter = typeof appRouter diff --git a/src/main/ipc/trpc.ts b/src/main/ipc/trpc.ts index 302e340..d1212fc 100644 --- a/src/main/ipc/trpc.ts +++ b/src/main/ipc/trpc.ts @@ -6,6 +6,7 @@ import type { ThreadService } from '../services/ThreadService' import type { ProviderManager } from '../../agents/providers/ProviderManager' import type { Orchestrator } from '../../agents/orchestrator/Orchestrator' import type { SettingsService } from '../services/SettingsService' +import type { OAuthService } from '../services/OAuthService' export interface Context { db: DatabaseService @@ -15,6 +16,7 @@ export interface Context { providerManager: ProviderManager orchestrator: Orchestrator settingsService: SettingsService + oauthService: OAuthService } const t = initTRPC.context().create() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6208751..30be92c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -5,6 +5,8 @@ interface KeystoneIPC { onAIDone: (callback: (data: { threadId: string; messageId: string | null }) => void) => unknown removeAIListeners: () => void selectDirectory: () => Promise + onOAuthStatus: (callback: (data: { state: string; provider?: string; email?: string; error?: string }) => void) => unknown + removeOAuthListeners: () => void } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 339a69d..9a6bce9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,5 +22,14 @@ process.once('loaded', () => { ipcRenderer.removeAllListeners('ai:done') }, selectDirectory: () => ipcRenderer.invoke('dialog:openDirectory'), + onOAuthStatus: (callback: (data: { state: string; provider?: string; email?: string; error?: string }) => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { state: string; provider?: string; email?: string; error?: string }) => + callback(data) + ipcRenderer.on('oauth:status', handler) + return handler + }, + removeOAuthListeners: () => { + ipcRenderer.removeAllListeners('oauth:status') + }, }) }) diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 9cdadca..abcf3e9 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -3,6 +3,8 @@ interface KeystoneIPC { onAIDone: (callback: (data: { threadId: string; messageId: string | null }) => void) => unknown removeAIListeners: () => void selectDirectory: () => Promise + onOAuthStatus: (callback: (data: { state: string; provider?: string; email?: string; error?: string }) => void) => unknown + removeOAuthListeners: () => void } interface Window { From 670bf92c3e1e5d4dbd5400db8ce01f448b33344f Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Thu, 5 Feb 2026 20:10:53 -0500 Subject: [PATCH 4/6] feat(ui): redesign settings with OAuth sign-in buttons New ProviderCard component: - OAuth sign-in button for supported providers (OpenAI, Google) - "Experimental" badge for unstable providers - Connected state shows email with disconnect button - API key notice for Anthropic (OAuth not supported) Redesigned SettingsDialog: - Provider cards with OAuth as primary auth method - Collapsible "Advanced: Use API Keys" section - OAuth status listener for real-time flow updates settingsStore extended with OAuth state: - oauthStatus per provider (connected, email, authMethod) - oauthFlowStatus (idle, pending, success, error) - startOAuthFlow, disconnectOAuth, updateOAuthFlowStatus actions Co-Authored-By: Claude Opus 4.5 --- .../features/settings/ProviderCard.tsx | 113 ++++++++++++++++++ .../features/settings/SettingsDialog.tsx | 89 ++++++++++---- src/renderer/stores/settingsStore.ts | 91 +++++++++++++- 3 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 src/renderer/features/settings/ProviderCard.tsx diff --git a/src/renderer/features/settings/ProviderCard.tsx b/src/renderer/features/settings/ProviderCard.tsx new file mode 100644 index 0000000..df7cccc --- /dev/null +++ b/src/renderer/features/settings/ProviderCard.tsx @@ -0,0 +1,113 @@ +import { Button } from '../../components/ui/Button' +import { useSettingsStore } from '../../stores/settingsStore' +import type { ProviderType } from '@shared/types' +import { OAUTH_CAPABILITIES } from '@shared/constants' + +interface ProviderCardProps { + type: ProviderType + name: string + isActive: boolean + onSelect: () => void +} + +export function ProviderCard({ type, name, isActive, onSelect }: ProviderCardProps) { + const { oauthStatus, oauthFlowStatus, startOAuthFlow, disconnectOAuth } = useSettingsStore() + + const oauth = oauthStatus[type] + const capabilities = OAUTH_CAPABILITIES[type] + const isOAuthSupported = capabilities?.supported ?? false + const isExperimental = capabilities?.experimental ?? false + const isPending = oauthFlowStatus.state === 'pending' && 'provider' in oauthFlowStatus && oauthFlowStatus.provider === type + const isError = oauthFlowStatus.state === 'error' && 'provider' in oauthFlowStatus && oauthFlowStatus.provider === type + + const handleSignIn = async () => { + await startOAuthFlow(type) + } + + const handleDisconnect = async () => { + await disconnectOAuth(type) + } + + return ( +
+
+ +
+
+ {name} + {isExperimental && ( + + Experimental + + )} +
+
+
+ + {/* OAuth section */} + {isOAuthSupported && ( +
+ {oauth.connected ? ( +
+
+
+ + Connected{oauth.email ? ` as ${oauth.email}` : ''} + +
+ +
+ ) : ( +
+ + {isError && 'error' in oauthFlowStatus && ( +

{oauthFlowStatus.error}

+ )} +
+ )} +
+ )} + + {/* API key only notice for Anthropic */} + {!isOAuthSupported && type === 'anthropic' && ( +
+ API key required (no OAuth available) +
+ )} +
+ ) +} diff --git a/src/renderer/features/settings/SettingsDialog.tsx b/src/renderer/features/settings/SettingsDialog.tsx index 294c1b1..50f0288 100644 --- a/src/renderer/features/settings/SettingsDialog.tsx +++ b/src/renderer/features/settings/SettingsDialog.tsx @@ -1,8 +1,9 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Dialog } from '../../components/ui/Dialog' import { Input } from '../../components/ui/Input' import { Button } from '../../components/ui/Button' import { useSettingsStore } from '../../stores/settingsStore' +import { ProviderCard } from './ProviderCard' import type { ProviderType } from '@shared/types' interface SettingsDialogProps { @@ -11,13 +12,14 @@ interface SettingsDialogProps { } const providers: Array<{ type: ProviderType; name: string; placeholder: string }> = [ - { type: 'anthropic', name: 'Anthropic (Claude)', placeholder: 'sk-ant-...' }, { type: 'openai', name: 'OpenAI', placeholder: 'sk-...' }, + { type: 'anthropic', name: 'Anthropic (Claude)', placeholder: 'sk-ant-...' }, { type: 'google', name: 'Google (Gemini)', placeholder: 'AI...' }, ] export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const { apiKeys, activeProvider, setApiKey, setActiveProvider, loadSettings } = useSettingsStore() + const [showAdvanced, setShowAdvanced] = useState(false) useEffect(() => { if (open) { @@ -25,34 +27,75 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { } }, [open, loadSettings]) + // Listen for OAuth status updates from main process + useEffect(() => { + if (!open) return + + window.keystoneIPC.onOAuthStatus((data) => { + useSettingsStore.getState().updateOAuthFlowStatus(data as any) + }) + + return () => { + window.keystoneIPC.removeOAuthListeners() + } + }, [open]) + return ( -
+
-

AI Provider

-
+

AI Providers

+
{providers.map((p) => ( -
-
- setActiveProvider(p.type)} - className="text-indigo-600" - /> - -
- setApiKey(p.type, e.target.value)} - placeholder={p.placeholder} - /> -
+ setActiveProvider(p.type)} + /> ))}
+ + {/* Collapsible Advanced section for API keys */} +
+ + + {showAdvanced && ( +
+ {providers.map((p) => ( +
+ + setApiKey(p.type, e.target.value)} + placeholder={p.placeholder} + /> +
+ ))} +
+ )} +
+
diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 815493a..6fecac3 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -1,19 +1,36 @@ import { create } from 'zustand' -import type { ProviderType } from '@shared/types' +import type { ProviderType, OAuthFlowStatus, AuthMethod } from '@shared/types' import { trpc } from '../lib/trpc' +interface OAuthProviderStatus { + connected: boolean + email: string | null + authMethod: AuthMethod +} + interface SettingsState { activeProvider: ProviderType | null apiKeys: Record + oauthStatus: Record + oauthFlowStatus: OAuthFlowStatus loaded: boolean setActiveProvider: (provider: ProviderType | null) => void setApiKey: (provider: ProviderType, key: string) => void loadSettings: () => Promise + startOAuthFlow: (provider: ProviderType) => Promise + disconnectOAuth: (provider: ProviderType) => Promise + updateOAuthFlowStatus: (status: OAuthFlowStatus) => void } export const useSettingsStore = create((set, get) => ({ activeProvider: null, apiKeys: { openai: '', anthropic: '', google: '' }, + oauthStatus: { + openai: { connected: false, email: null, authMethod: 'apiKey' }, + anthropic: { connected: false, email: null, authMethod: 'apiKey' }, + google: { connected: false, email: null, authMethod: 'apiKey' }, + }, + oauthFlowStatus: { state: 'idle' }, loaded: false, setActiveProvider: async (provider) => { @@ -31,7 +48,52 @@ export const useSettingsStore = create((set, get) => ({ // Also configure the provider manager in-memory for immediate use if (key) { - await trpc.ai.configureProvider.mutate({ type: provider, apiKey: key }) + await trpc.ai.configureProvider.mutate({ type: provider, apiKey: key, authMethod: 'apiKey' }) + } + }, + + startOAuthFlow: async (provider) => { + set({ oauthFlowStatus: { state: 'pending', provider } }) + try { + const result = await trpc.oauth.startFlow.mutate({ provider }) + set((state) => ({ + oauthFlowStatus: { state: 'success', provider, email: result.email }, + oauthStatus: { + ...state.oauthStatus, + [provider]: { connected: true, email: result.email ?? null, authMethod: 'oauth' as AuthMethod }, + }, + activeProvider: provider, + })) + } catch (err) { + const message = err instanceof Error ? err.message : 'OAuth flow failed' + set({ oauthFlowStatus: { state: 'error', provider, error: message } }) + } + }, + + disconnectOAuth: async (provider) => { + await trpc.oauth.disconnect.mutate({ provider }) + set((state) => ({ + oauthStatus: { + ...state.oauthStatus, + [provider]: { connected: false, email: null, authMethod: 'apiKey' as AuthMethod }, + }, + oauthFlowStatus: { state: 'idle' }, + })) + }, + + updateOAuthFlowStatus: (status) => { + set({ oauthFlowStatus: status }) + if (status.state === 'success' && 'provider' in status) { + set((state) => ({ + oauthStatus: { + ...state.oauthStatus, + [status.provider]: { + connected: true, + email: status.email ?? null, + authMethod: 'oauth' as AuthMethod, + }, + }, + })) } }, @@ -50,13 +112,34 @@ export const useSettingsStore = create((set, get) => ({ } } + // Load OAuth status for each provider + const oauthStatus: Record = { + openai: { connected: false, email: null, authMethod: 'apiKey' }, + anthropic: { connected: false, email: null, authMethod: 'apiKey' }, + google: { connected: false, email: null, authMethod: 'apiKey' }, + } + + for (const provider of ['openai', 'anthropic', 'google'] as ProviderType[]) { + try { + const status = await trpc.oauth.getStatus.query({ provider }) + oauthStatus[provider] = { + connected: status.connected, + email: status.email, + authMethod: status.authMethod as AuthMethod, + } + } catch { + // OAuth status query failed, keep defaults + } + } + set({ activeProvider: settings.activeProvider as ProviderType | null, apiKeys, - loaded: true + oauthStatus, + loaded: true, }) } catch (error) { console.error('Failed to load settings:', error) } - } + }, })) From 32089475240217b70ffc2b1d8f5a8e5e5b1c06a0 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 6 Feb 2026 00:05:06 -0500 Subject: [PATCH 5/6] fix(auth): use OpenAI's required redirect URI format OpenAI OAuth requires specific redirect configuration: - Fixed port 1455 (not random) - Callback path /auth/callback (not /callback) - localhost hostname (not 127.0.0.1) Added redirectPort and callbackPath to OAUTH_CAPABILITIES. Updated OAuthService to use provider-specific settings. Co-Authored-By: Claude Opus 4.5 --- src/main/services/OAuthService.ts | 12 ++++++++---- src/shared/constants.ts | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/services/OAuthService.ts b/src/main/services/OAuthService.ts index fbf60ba..7e0cada 100644 --- a/src/main/services/OAuthService.ts +++ b/src/main/services/OAuthService.ts @@ -60,10 +60,12 @@ export class OAuthService { } } + const callbackPath = capabilities.callbackPath || '/callback' + const server = http.createServer(async (req, res) => { const url = new URL(req.url || '/', `http://127.0.0.1`) - if (url.pathname !== '/callback') { + if (url.pathname !== callbackPath) { res.writeHead(404) res.end('Not found') return @@ -108,8 +110,9 @@ export class OAuthService { } }) - // Listen on random port on loopback - server.listen(0, '127.0.0.1', () => { + // Listen on provider-specific port (or random port if not specified) + const listenPort = capabilities.redirectPort || 0 + server.listen(listenPort, '127.0.0.1', () => { const addr = server.address() if (!addr || typeof addr === 'string') { settle(() => reject(new OAuthError(provider, 'Failed to start loopback server'))) @@ -117,7 +120,8 @@ export class OAuthService { } this.activeServer = server - redirectUri = `http://127.0.0.1:${addr.port}/callback` + // Use localhost (not 127.0.0.1) as some providers require it + redirectUri = `http://localhost:${addr.port}${callbackPath}` // Build authorization URL const params = new URLSearchParams({ diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7733106..9dbd557 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -17,6 +17,8 @@ export const OAUTH_CAPABILITIES: Record supportsTokenExchange?: boolean experimental?: boolean + redirectPort?: number // Fixed port for OAuth callback (required by some providers) + callbackPath?: string // Callback path (default: /callback) }> = { openai: { supported: true, @@ -29,6 +31,8 @@ export const OAUTH_CAPABILITIES: Record Date: Fri, 6 Feb 2026 00:05:20 -0500 Subject: [PATCH 6/6] chore: add app icons and social preview image Co-Authored-By: Claude Opus 4.5 --- package.json | 5 +++-- resources/icon.png | Bin 0 -> 18889 bytes resources/social-preview.png | Bin 0 -> 22052 bytes 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 resources/icon.png create mode 100644 resources/social-preview.png diff --git a/package.json b/package.json index 62ec2e4..841e419 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "appId": "com.dortort.keystone", "productName": "Keystone", "directories": { - "output": "release" + "output": "release", + "buildResources": "resources" }, "files": [ "dist/**/*" @@ -90,4 +91,4 @@ ] } } -} +} \ No newline at end of file diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cf4fbf330c583f242b231b79e2a0b8e4088bc254 GIT binary patch literal 18889 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%0AtrGK@v{ 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 DIJsFA literal 0 HcmV?d00001