mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +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
BIN
apps/docs/docs/img/google-setup/07-enter-credentials.png
Normal file
BIN
apps/docs/docs/img/google-setup/07-enter-credentials.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
setGoogleClientIdOpen(true)
|
||||||
if (!existingClientId) {
|
|
||||||
setGoogleClientIdOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await startConnect(provider, existingClientId)
|
|
||||||
return
|
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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
setGoogleClientIdOpen(true)
|
||||||
if (!existingClientId) {
|
|
||||||
setGoogleClientIdOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await startConnect(provider, existingClientId)
|
|
||||||
return
|
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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
setGoogleClientIdOpen(true)
|
||||||
if (!existingClientId) {
|
|
||||||
setGoogleClientIdOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await startConnect(provider, existingClientId)
|
|
||||||
return
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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**
|
|
||||||
|
|
||||||

|
### 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue