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

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View file

@ -460,7 +460,10 @@ export function setupIpcHandlers() {
return { success: true }; return { success: true };
}, },
'oauth:connect': async (_event, args) => { '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) => { 'oauth:disconnect': async (_event, args) => {
return await disconnectProvider(args.provider); return await disconnectProvider(args.provider);

View file

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

View file

@ -17,7 +17,7 @@ const GOOGLE_CLIENT_ID_SETUP_GUIDE_URL =
interface GoogleClientIdModalProps { interface GoogleClientIdModalProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onSubmit: (clientId: string) => void onSubmit: (clientId: string, clientSecret: string) => void
isSubmitting?: boolean isSubmitting?: boolean
description?: string description?: string
} }
@ -30,19 +30,22 @@ export function GoogleClientIdModal({
description, description,
}: GoogleClientIdModalProps) { }: GoogleClientIdModalProps) {
const [clientId, setClientId] = useState("") const [clientId, setClientId] = useState("")
const [clientSecret, setClientSecret] = useState("")
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setClientId("") setClientId("")
setClientSecret("")
} }
}, [open]) }, [open])
const trimmedClientId = clientId.trim() const trimmedClientId = clientId.trim()
const isValid = trimmedClientId.length > 0 const trimmedClientSecret = clientSecret.trim()
const isValid = trimmedClientId.length > 0 && trimmedClientSecret.length > 0
const handleSubmit = () => { const handleSubmit = () => {
if (!isValid || isSubmitting) return if (!isValid || isSubmitting) return
onSubmit(trimmedClientId) onSubmit(trimmedClientId, trimmedClientSecret)
} }
return ( 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"> <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"> <div className="p-6 pb-0">
<DialogHeader className="space-y-1.5"> <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"> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
</div> </div>
@ -76,6 +79,25 @@ export function GoogleClientIdModal({
autoFocus autoFocus
/> />
</div> </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"> <p className="text-xs text-muted-foreground">
Need help?{" "} Need help?{" "}
<a <a

View file

@ -23,7 +23,7 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { GoogleClientIdModal } from "@/components/google-client-id-modal" 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 { toast } from "sonner"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
@ -517,10 +517,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
isConnecting: false, 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) { } catch (error) {
console.error('Failed to check connection status for providers:', error) console.error('Failed to check connection status for providers:', error)
for (const provider of providers) { 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 => ({ setProviderStates(prev => ({
...prev, ...prev,
[provider]: { ...prev[provider], isConnecting: true } [provider]: { ...prev[provider], isConnecting: true }
})) }))
try { 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) { if (!result.success) {
toast.error(result.error || `Failed to connect to ${provider}`) toast.error(result.error || `Failed to connect to ${provider}`)
@ -622,22 +618,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Connect to a provider // Connect to a provider
const handleConnect = useCallback(async (provider: string) => { const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') { if (provider === 'google') {
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true) setGoogleClientIdOpen(true)
return return
} }
await startConnect(provider, existingClientId)
return
}
await startConnect(provider) await startConnect(provider)
}, [startConnect]) }, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => { const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleClientId(clientId) setGoogleCredentials(clientId, clientSecret)
setGoogleClientIdOpen(false) setGoogleClientIdOpen(false)
startConnect('google', clientId) startConnect('google', { clientId, clientSecret })
}, [startConnect]) }, [startConnect])
// Step indicator - dynamic based on path // Step indicator - dynamic based on path

View file

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

View file

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

View file

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

View file

@ -37,9 +37,10 @@ function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens {
*/ */
export async function discoverConfiguration( export async function discoverConfiguration(
issuerUrl: string, issuerUrl: string,
clientId: string clientId: string,
clientSecret?: string
): Promise<client.Configuration> { ): Promise<client.Configuration> {
const cacheKey = `${issuerUrl}:${clientId}`; const cacheKey = `${issuerUrl}:${clientId}:${clientSecret ? 'secret' : 'none'}`;
const cached = configCache.get(cacheKey); const cached = configCache.get(cacheKey);
if (cached) { if (cached) {
@ -50,8 +51,8 @@ export async function discoverConfiguration(
const config = await client.discovery( const config = await client.discovery(
new URL(issuerUrl), new URL(issuerUrl),
clientId, clientId,
undefined, // no client_secret (PKCE flow) clientSecret ?? undefined,
client.None(), // PKCE doesn't require client authentication clientSecret ? client.ClientSecretPost(clientSecret) : client.None(),
{ {
execute: [client.allowInsecureRequests], execute: [client.allowInsecureRequests],
} }
@ -69,7 +70,8 @@ export function createStaticConfiguration(
authorizationEndpoint: string, authorizationEndpoint: string,
tokenEndpoint: string, tokenEndpoint: string,
clientId: string, clientId: string,
revocationEndpoint?: string revocationEndpoint?: string,
clientSecret?: string
): client.Configuration { ): client.Configuration {
console.log(`[OAuth] Creating static configuration (no discovery)`); console.log(`[OAuth] Creating static configuration (no discovery)`);
@ -86,8 +88,8 @@ export function createStaticConfiguration(
return new client.Configuration( return new client.Configuration(
serverMetadata, serverMetadata,
clientId, clientId,
undefined, // no client_secret clientSecret ?? undefined,
client.None() // PKCE auth clientSecret ? client.ClientSecretPost(clientSecret) : client.None()
); );
} }

View file

@ -7,6 +7,7 @@ import z from 'zod';
const ProviderConnectionSchema = z.object({ const ProviderConnectionSchema = z.object({
tokens: OAuthTokens.nullable().optional(), tokens: OAuthTokens.nullable().optional(),
clientId: z.string().nullable().optional(), clientId: z.string().nullable().optional(),
clientSecret: z.string().nullable().optional(),
error: z.string().nullable().optional(), error: z.string().nullable().optional(),
}); });

View file

@ -18,21 +18,23 @@ export class GoogleClientFactory {
client: OAuth2Client | null; client: OAuth2Client | null;
tokens: OAuthTokens | null; tokens: OAuthTokens | null;
clientId: string | null; clientId: string | null;
clientSecret: string | null;
} = { } = {
config: null, config: null,
client: null, client: null,
tokens: null, tokens: null,
clientId: null, clientId: null,
clientSecret: null,
}; };
private static async resolveClientId(): Promise<string> { private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo'); const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { clientId } = await oauthRepo.read(this.PROVIDER_NAME); const connection = await oauthRepo.read(this.PROVIDER_NAME);
if (!clientId) { if (!connection.clientId) {
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' }); await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' });
throw new Error('Google client ID missing. Please reconnect.'); throw new Error('Google client ID missing. Please reconnect.');
} }
return clientId; return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined };
} }
/** /**
@ -82,9 +84,11 @@ export class GoogleClientFactory {
// Update cached tokens and recreate client // Update cached tokens and recreate client
this.cache.tokens = refreshedTokens; this.cache.tokens = refreshedTokens;
if (!this.cache.clientId) { if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId(); const creds = await this.resolveCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
} }
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId); this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
console.log(`[OAuth] Token refreshed successfully`); console.log(`[OAuth] Token refreshed successfully`);
return this.cache.client; return this.cache.client;
} catch (error) { } catch (error) {
@ -105,9 +109,11 @@ export class GoogleClientFactory {
console.log(`[OAuth] Creating new OAuth2Client instance`); console.log(`[OAuth] Creating new OAuth2Client instance`);
this.cache.tokens = tokens; this.cache.tokens = tokens;
if (!this.cache.clientId) { if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId(); const creds = await this.resolveCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
} }
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId); this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
return this.cache.client; return this.cache.client;
} }
@ -138,19 +144,20 @@ export class GoogleClientFactory {
this.cache.client = null; this.cache.client = null;
this.cache.tokens = null; this.cache.tokens = null;
this.cache.clientId = null; this.cache.clientId = null;
this.cache.clientSecret = null;
} }
/** /**
* Initialize cached configuration (called once) * Initialize cached configuration (called once)
*/ */
private static async initializeConfigCache(): Promise<void> { private static async initializeConfigCache(): Promise<void> {
const clientId = await this.resolveClientId(); const { clientId, clientSecret } = await this.resolveCredentials();
if (this.cache.config && this.cache.clientId === clientId) { if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
return; // Already initialized for this client ID return; // Already initialized for these credentials
} }
if (this.cache.clientId && this.cache.clientId !== clientId) { if (this.cache.clientId && (this.cache.clientId !== clientId || this.cache.clientSecret !== (clientSecret ?? null))) {
this.clearCache(); this.clearCache();
} }
@ -163,7 +170,8 @@ export class GoogleClientFactory {
console.log(`[OAuth] Discovery mode: issuer with static client ID`); console.log(`[OAuth] Discovery mode: issuer with static client ID`);
this.cache.config = await oauthClient.discoverConfiguration( this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer, providerConfig.discovery.issuer,
clientId clientId,
clientSecret
); );
} else { } else {
// DCR mode - need existing registration // DCR mode - need existing registration
@ -191,22 +199,23 @@ export class GoogleClientFactory {
providerConfig.discovery.authorizationEndpoint, providerConfig.discovery.authorizationEndpoint,
providerConfig.discovery.tokenEndpoint, providerConfig.discovery.tokenEndpoint,
clientId, clientId,
providerConfig.discovery.revocationEndpoint providerConfig.discovery.revocationEndpoint,
clientSecret
); );
} }
this.cache.clientId = clientId; this.cache.clientId = clientId;
this.cache.clientSecret = clientSecret ?? null;
console.log(`[OAuth] Google OAuth configuration initialized`); console.log(`[OAuth] Google OAuth configuration initialized`);
} }
/** /**
* Create OAuth2Client from OAuthTokens * Create OAuth2Client from OAuthTokens
*/ */
private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client { private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
const client = new OAuth2Client( const client = new OAuth2Client(
clientId, clientId,
undefined, // client_secret not needed for PKCE clientSecret ?? undefined,
undefined // redirect_uri not needed for token usage undefined // redirect_uri not needed for token usage
); );

View file

@ -225,6 +225,7 @@ const ipcSchemas = {
req: z.object({ req: z.object({
provider: z.string(), provider: z.string(),
clientId: z.string().optional(), clientId: z.string().optional(),
clientSecret: z.string().optional(),
}), }),
res: z.object({ res: z.object({
success: z.boolean(), success: z.boolean(),

View file

@ -1,6 +1,6 @@
# Connecting Google to Rowboat # Connecting Google to Rowboat
Rowboat requires a Google OAuth Client ID to connect to Gmail, Calendar, and Drive. Follow the steps below to generate your Client ID correctly. Rowboat requires Google OAuth credentials (Client ID and Client Secret) to connect to Gmail, Calendar, and Drive. Follow the steps below to generate them.
--- ---
@ -114,34 +114,32 @@ Click **Create Credentials → OAuth Client ID**
Select: Select:
**Universal Windows Platform (UWP)** **Web application**
- Name it anything (e.g. `Rowboat Desktop`) - Name it anything (e.g. `Rowboat Desktop`)
- Store ID can be anything (e.g. `test` )
- Click **Create**
![Create OAuth Client ID (UWP)](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png) ### Authorized redirect URIs
### Authorized redirect URIs (if shown) Add the following redirect URI:
If your OAuth client configuration shows **Authorized redirect URIs**, add:
- `http://localhost:8080/oauth/callback` - `http://localhost:8080/oauth/callback`
Use this exactly: no trailing slash, port **8080**. This must match what the app uses for the OAuth callback. (Some client types, e.g. UWP, may not expose redirect URIs; that is fine.) Use this exactly: no trailing slash, port **8080**. This must match what the app uses for the OAuth callback.
Click **Create**.
--- ---
## 7⃣ Copy the Client ID ## 7⃣ Copy the Client ID and Client Secret
After creation, Google will show: After creation, Google will show:
- **Client ID** - **Client ID**
- **Client Secret** - **Client Secret**
Copy the **Client ID** and paste it into Rowboat where prompted. Copy **both values** and paste them into Rowboat when prompted.
![Copy Client ID](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/06-copy-client-id.png) ![Enter credentials in Rowboat](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/07-enter-credentials.png)
--- ---
@ -152,7 +150,7 @@ Copy the **Client ID** and paste it into Rowboat where prompted.
If the browser shows "Authorization Successful" but the app then shows an error (e.g. "invalid response encountered" or "response parameter \"iss\" (issuer) missing"): If the browser shows "Authorization Successful" but the app then shows an error (e.g. "invalid response encountered" or "response parameter \"iss\" (issuer) missing"):
1. **Check the app logs** (e.g. terminal or dev tools) for the full error. The message there will often indicate the cause (e.g. redirect URI mismatch, missing parameter). 1. **Check the app logs** (e.g. terminal or dev tools) for the full error. The message there will often indicate the cause (e.g. redirect URI mismatch, missing parameter).
2. **Verify redirect URI in Google Cloud Console**: Open [Credentials → your OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients). If the client type allows **Authorized redirect URIs**, ensure `http://localhost:8080/oauth/callback` is listed exactly. 2. **Verify redirect URI in Google Cloud Console**: Open [Credentials → your OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients). Ensure `http://localhost:8080/oauth/callback` is listed under **Authorized redirect URIs**.
3. **Client type**: Use **Desktop** or **UWP** as the application type. A "Web application" client may require the redirect URI to be set and can behave differently with localhost. 3. **Client type**: Make sure you selected **Web application** as the application type. Other types (Desktop, UWP) may not provide a client secret or may handle redirect URIs differently.
--- ---