mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-07 14:22:38 +02:00
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:
parent
924e136505
commit
50bce6c1d6
15 changed files with 155 additions and 138 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
23
apps/x/apps/renderer/src/lib/google-credentials-store.ts
Normal file
23
apps/x/apps/renderer/src/lib/google-credentials-store.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue