feat(oauth): switch Google OAuth from PKCE to authorization code flow with client secret

Previously, the Google OAuth integration used a PKCE-only flow (no client
secret). This switches to a standard authorization code flow where the user
provides both a Client ID and Client Secret from a "Web application" type
OAuth client in Google Cloud Console. PKCE is retained alongside the secret
for defense in depth.

Key changes:

- oauth-client.ts: discoverConfiguration() and createStaticConfiguration()
  now accept an optional clientSecret param. When provided, uses
  ClientSecretPost instead of None() for client authentication.

- oauth-handler.ts: connectProvider() takes a credentials object
  ({clientId, clientSecret}) instead of a bare clientId. Removed eager
  persistence of clientId before flow completion — credentials are now
  only saved after successful token exchange. Renamed resolveClientId to
  resolveClientCredentials to return both values from a single repo read.

- google-client-factory.ts: same resolveClientId → resolveCredentials
  rename. Passes clientSecret to OAuth2Client constructor and
  discoverConfiguration for token refresh.

- repo.ts: added clientSecret to ProviderConnectionSchema. Not exposed
  to renderer via ClientFacingConfigSchema (stays main-process only).

- IPC: added clientSecret to oauth:connect request schema. Handler builds
  a credentials object and passes it through.

- UI: GoogleClientIdModal now collects both Client ID and Client Secret
  (password field). Always shown on connect — no in-memory credential
  caching. Renamed google-client-id-store to google-credentials-store
  with a unified {clientId, clientSecret} object.

- google-setup.md: updated to instruct users to create a "Web application"
  type OAuth client (instead of UWP), add the localhost redirect URI, and
  copy both Client ID and Client Secret. Added credentials modal screenshot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-04-10 00:42:59 +05:30
parent 924e136505
commit 50bce6c1d6
15 changed files with 155 additions and 138 deletions

View file

@ -460,7 +460,10 @@ export function setupIpcHandlers() {
return { success: true };
},
'oauth:connect': async (_event, args) => {
return await connectProvider(args.provider, args.clientId?.trim());
const credentials = args.clientId && args.clientSecret
? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() }
: undefined;
return await connectProvider(args.provider, credentials);
},
'oauth:disconnect': async (_event, args) => {
return await disconnectProvider(args.provider);

View file

@ -111,19 +111,19 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
/**
* Get or create OAuth configuration for a provider
*/
async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise<Configuration> {
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
const config = await getProviderConfig(provider);
const resolveClientId = async (): Promise<string> => {
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
if (config.client.mode === 'static' && config.client.clientId) {
return config.client.clientId;
return { clientId: config.client.clientId, clientSecret: credentialsOverride?.clientSecret };
}
if (clientIdOverride) {
return clientIdOverride;
if (credentialsOverride) {
return { clientId: credentialsOverride.clientId, clientSecret: credentialsOverride.clientSecret };
}
const oauthRepo = getOAuthRepo();
const { clientId } = await oauthRepo.read(provider);
if (clientId) {
return clientId;
const connection = await oauthRepo.read(provider);
if (connection.clientId) {
return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined };
}
throw new Error(`${provider} client ID not configured. Please provide a client ID.`);
};
@ -132,10 +132,11 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
if (config.client.mode === 'static') {
// Discover endpoints, use static client ID
console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`);
const clientId = await resolveClientId();
const { clientId, clientSecret } = await resolveClientCredentials();
return await oauthClient.discoverConfiguration(
config.discovery.issuer,
clientId
clientId,
clientSecret
);
} else {
// DCR mode - check for existing registration or register new
@ -172,12 +173,13 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
}
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
const clientId = await resolveClientId();
const { clientId, clientSecret } = await resolveClientCredentials();
return oauthClient.createStaticConfiguration(
config.discovery.authorizationEndpoint,
config.discovery.tokenEndpoint,
clientId,
config.discovery.revocationEndpoint
config.discovery.revocationEndpoint,
clientSecret
);
}
}
@ -185,7 +187,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
/**
* Initiate OAuth flow for a provider
*/
export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> {
export async function connectProvider(provider: string, credentials?: { clientId: string; clientSecret: string }): Promise<{ success: boolean; error?: string }> {
try {
console.log(`[OAuth] Starting connection flow for ${provider}...`);
@ -196,18 +198,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
const providerConfig = await getProviderConfig(provider);
if (provider === 'google') {
if (!clientId) {
return { success: false, error: 'Google client ID is required to connect.' };
if (!credentials?.clientId || !credentials?.clientSecret) {
return { success: false, error: 'Google client ID and client secret are required to connect.' };
}
}
// Get or create OAuth configuration
const config = await getProviderConfiguration(provider, clientId);
// Persist Google client ID so it survives restarts and failed token exchanges
if (provider === 'google' && clientId) {
await oauthRepo.upsert(provider, { clientId });
}
const config = await getProviderConfiguration(provider, credentials);
// Generate PKCE codes
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
@ -258,13 +255,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
state
);
// Save tokens
// Save tokens and credentials
console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.upsert(provider, { tokens });
if (provider === 'google' && clientId) {
await oauthRepo.upsert(provider, { clientId });
}
await oauthRepo.upsert(provider, { error: null });
await oauthRepo.upsert(provider, {
tokens,
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
error: null,
});
// Trigger immediate sync for relevant providers
if (provider === 'google') {

View file

@ -17,7 +17,7 @@ const GOOGLE_CLIENT_ID_SETUP_GUIDE_URL =
interface GoogleClientIdModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (clientId: string) => void
onSubmit: (clientId: string, clientSecret: string) => void
isSubmitting?: boolean
description?: string
}
@ -30,19 +30,22 @@ export function GoogleClientIdModal({
description,
}: GoogleClientIdModalProps) {
const [clientId, setClientId] = useState("")
const [clientSecret, setClientSecret] = useState("")
useEffect(() => {
if (!open) {
setClientId("")
setClientSecret("")
}
}, [open])
const trimmedClientId = clientId.trim()
const isValid = trimmedClientId.length > 0
const trimmedClientSecret = clientSecret.trim()
const isValid = trimmedClientId.length > 0 && trimmedClientSecret.length > 0
const handleSubmit = () => {
if (!isValid || isSubmitting) return
onSubmit(trimmedClientId)
onSubmit(trimmedClientId, trimmedClientSecret)
}
return (
@ -50,9 +53,9 @@ export function GoogleClientIdModal({
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
<div className="p-6 pb-0">
<DialogHeader className="space-y-1.5">
<DialogTitle className="text-lg font-semibold">Google Client ID</DialogTitle>
<DialogTitle className="text-lg font-semibold">Google OAuth Credentials</DialogTitle>
<DialogDescription className="text-sm">
{description ?? "Enter the client ID for your Google OAuth app to connect."}
{description ?? "Enter the credentials for your Google OAuth app to connect."}
</DialogDescription>
</DialogHeader>
</div>
@ -76,6 +79,25 @@ export function GoogleClientIdModal({
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-secret">
Client Secret
</label>
<Input
id="google-client-secret"
type="password"
placeholder="GOCSPX-..."
value={clientSecret}
onChange={(event) => setClientSecret(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
className="font-mono text-xs"
/>
</div>
<p className="text-xs text-muted-foreground">
Need help?{" "}
<a

View file

@ -23,7 +23,7 @@ import {
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
import { setGoogleCredentials } from "@/lib/google-credentials-store"
import { toast } from "sonner"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
@ -517,10 +517,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
isConnecting: false,
}
}
// Hydrate in-memory Google client ID from persisted config so Connect can skip re-entry
if (config.google?.clientId) {
setGoogleClientId(config.google.clientId)
}
} catch (error) {
console.error('Failed to check connection status for providers:', error)
for (const provider of providers) {
@ -593,14 +589,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
if (!result.success) {
toast.error(result.error || `Failed to connect to ${provider}`)
@ -622,22 +618,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
return
}
await startConnect(provider, existingClientId)
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
setGoogleClientIdOpen(false)
startConnect('google', clientId)
startConnect('google', { clientId, clientSecret })
}, [startConnect])
// Step indicator - dynamic based on path

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react"
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
import { setGoogleCredentials } from "@/lib/google-credentials-store"
import { toast } from "sonner"
export interface ProviderState {
@ -576,14 +576,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
return cleanup
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
if (!result.success) {
toast.error(result.error || `Failed to connect to ${provider}`)
@ -605,22 +605,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
return
}
await startConnect(provider, existingClientId)
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
setGoogleClientIdOpen(false)
startConnect('google', clientId)
startConnect('google', { clientId, clientSecret })
}, [startConnect])
// Switch to rowboat path from BYOK inline callout

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react"
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
import { setGoogleCredentials, clearGoogleCredentials } from "@/lib/google-credentials-store"
import { toast } from "sonner"
export interface ProviderState {
@ -318,14 +318,14 @@ export function useConnectors(active: boolean) {
}, [startGmailConnect])
// OAuth connect/disconnect
const startConnect = useCallback(async (provider: string, clientId?: string) => {
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
if (!result.success) {
toast.error(result.error || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`))
@ -347,23 +347,18 @@ export function useConnectors(active: boolean) {
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
setGoogleClientIdDescription(undefined)
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
return
}
await startConnect(provider, existingClientId)
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
setGoogleClientIdOpen(false)
setGoogleClientIdDescription(undefined)
startConnect('google', clientId)
startConnect('google', { clientId, clientSecret })
}, [startConnect])
const handleDisconnect = useCallback(async (provider: string) => {
@ -377,7 +372,7 @@ export function useConnectors(active: boolean) {
if (result.success) {
if (provider === 'google') {
clearGoogleClientId()
clearGoogleCredentials()
}
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
toast.success(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`)
@ -426,9 +421,6 @@ export function useConnectors(active: boolean) {
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
if (config.google?.clientId) {
setGoogleClientId(config.google.clientId)
}
const statusMap: Record<string, ProviderStatus> = {}
for (const provider of providers) {
@ -565,7 +557,7 @@ export function useConnectors(active: boolean) {
handleDisconnect,
startConnect,
// Google client ID modal
// Google credentials modal
googleClientIdOpen,
setGoogleClientIdOpen,
googleClientIdDescription,

View file

@ -55,10 +55,10 @@ export function useOAuth(provider: string) {
return cleanup;
}, [provider, checkConnection]);
const connect = useCallback(async (clientId?: string) => {
const connect = useCallback(async (credentials?: { clientId: string; clientSecret: string }) => {
try {
setIsConnecting(true);
const result = await window.ipc.invoke('oauth:connect', { provider, clientId });
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret });
if (result.success) {
// OAuth flow started - keep isConnecting state, wait for event
// Event listener will handle the actual completion

View file

@ -1,17 +0,0 @@
let googleClientId: string | null = null;
export function getGoogleClientId(): string | null {
return googleClientId;
}
export function setGoogleClientId(clientId: string): void {
const trimmed = clientId.trim();
if (!trimmed) {
return;
}
googleClientId = trimmed;
}
export function clearGoogleClientId(): void {
googleClientId = null;
}

View file

@ -0,0 +1,23 @@
interface GoogleCredentials {
clientId: string;
clientSecret: string;
}
let credentials: GoogleCredentials | null = null;
export function getGoogleCredentials(): GoogleCredentials | null {
return credentials;
}
export function setGoogleCredentials(clientId: string, clientSecret: string): void {
const trimmedId = clientId.trim();
const trimmedSecret = clientSecret.trim();
if (!trimmedId || !trimmedSecret) {
return;
}
credentials = { clientId: trimmedId, clientSecret: trimmedSecret };
}
export function clearGoogleCredentials(): void {
credentials = null;
}