mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-24 16:06:25 +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 };
|
||||
},
|
||||
'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;
|
||||
}
|
||||
|
|
@ -37,9 +37,10 @@ function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens {
|
|||
*/
|
||||
export async function discoverConfiguration(
|
||||
issuerUrl: string,
|
||||
clientId: string
|
||||
clientId: string,
|
||||
clientSecret?: string
|
||||
): Promise<client.Configuration> {
|
||||
const cacheKey = `${issuerUrl}:${clientId}`;
|
||||
const cacheKey = `${issuerUrl}:${clientId}:${clientSecret ? 'secret' : 'none'}`;
|
||||
|
||||
const cached = configCache.get(cacheKey);
|
||||
if (cached) {
|
||||
|
|
@ -50,8 +51,8 @@ export async function discoverConfiguration(
|
|||
const config = await client.discovery(
|
||||
new URL(issuerUrl),
|
||||
clientId,
|
||||
undefined, // no client_secret (PKCE flow)
|
||||
client.None(), // PKCE doesn't require client authentication
|
||||
clientSecret ?? undefined,
|
||||
clientSecret ? client.ClientSecretPost(clientSecret) : client.None(),
|
||||
{
|
||||
execute: [client.allowInsecureRequests],
|
||||
}
|
||||
|
|
@ -69,7 +70,8 @@ export function createStaticConfiguration(
|
|||
authorizationEndpoint: string,
|
||||
tokenEndpoint: string,
|
||||
clientId: string,
|
||||
revocationEndpoint?: string
|
||||
revocationEndpoint?: string,
|
||||
clientSecret?: string
|
||||
): client.Configuration {
|
||||
console.log(`[OAuth] Creating static configuration (no discovery)`);
|
||||
|
||||
|
|
@ -86,8 +88,8 @@ export function createStaticConfiguration(
|
|||
return new client.Configuration(
|
||||
serverMetadata,
|
||||
clientId,
|
||||
undefined, // no client_secret
|
||||
client.None() // PKCE auth
|
||||
clientSecret ?? undefined,
|
||||
clientSecret ? client.ClientSecretPost(clientSecret) : client.None()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import z from 'zod';
|
|||
const ProviderConnectionSchema = z.object({
|
||||
tokens: OAuthTokens.nullable().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
clientSecret: z.string().nullable().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,21 +18,23 @@ export class GoogleClientFactory {
|
|||
client: OAuth2Client | null;
|
||||
tokens: OAuthTokens | null;
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
} = {
|
||||
config: null,
|
||||
client: null,
|
||||
tokens: 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 { clientId } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
if (!clientId) {
|
||||
const connection = await oauthRepo.read(this.PROVIDER_NAME);
|
||||
if (!connection.clientId) {
|
||||
await oauthRepo.upsert(this.PROVIDER_NAME, { 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
|
||||
this.cache.tokens = refreshedTokens;
|
||||
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`);
|
||||
return this.cache.client;
|
||||
} catch (error) {
|
||||
|
|
@ -105,9 +109,11 @@ export class GoogleClientFactory {
|
|||
console.log(`[OAuth] Creating new OAuth2Client instance`);
|
||||
this.cache.tokens = tokens;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -138,19 +144,20 @@ export class GoogleClientFactory {
|
|||
this.cache.client = null;
|
||||
this.cache.tokens = null;
|
||||
this.cache.clientId = null;
|
||||
this.cache.clientSecret = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cached configuration (called once)
|
||||
*/
|
||||
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) {
|
||||
return; // Already initialized for this client ID
|
||||
if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -163,18 +170,19 @@ export class GoogleClientFactory {
|
|||
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
|
||||
this.cache.config = await oauthClient.discoverConfiguration(
|
||||
providerConfig.discovery.issuer,
|
||||
clientId
|
||||
clientId,
|
||||
clientSecret
|
||||
);
|
||||
} else {
|
||||
// DCR mode - need existing registration
|
||||
console.log(`[OAuth] Discovery mode: issuer with DCR`);
|
||||
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
|
||||
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
|
||||
|
||||
|
||||
if (!existingRegistration) {
|
||||
throw new Error('Google client not registered. Please connect account first.');
|
||||
}
|
||||
|
||||
|
||||
this.cache.config = await oauthClient.discoverConfiguration(
|
||||
providerConfig.discovery.issuer,
|
||||
existingRegistration.client_id
|
||||
|
|
@ -185,28 +193,29 @@ export class GoogleClientFactory {
|
|||
if (providerConfig.client.mode !== 'static') {
|
||||
throw new Error('DCR requires discovery mode "issuer", not "static"');
|
||||
}
|
||||
|
||||
|
||||
console.log(`[OAuth] Using static endpoints (no discovery)`);
|
||||
this.cache.config = oauthClient.createStaticConfiguration(
|
||||
providerConfig.discovery.authorizationEndpoint,
|
||||
providerConfig.discovery.tokenEndpoint,
|
||||
clientId,
|
||||
providerConfig.discovery.revocationEndpoint
|
||||
providerConfig.discovery.revocationEndpoint,
|
||||
clientSecret
|
||||
);
|
||||
}
|
||||
|
||||
this.cache.clientId = clientId;
|
||||
this.cache.clientSecret = clientSecret ?? null;
|
||||
console.log(`[OAuth] Google OAuth configuration initialized`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OAuth2Client from OAuthTokens
|
||||
*/
|
||||
private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client {
|
||||
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
|
||||
private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
|
||||
const client = new OAuth2Client(
|
||||
clientId,
|
||||
undefined, // client_secret not needed for PKCE
|
||||
clientSecret ?? undefined,
|
||||
undefined // redirect_uri not needed for token usage
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ const ipcSchemas = {
|
|||
req: z.object({
|
||||
provider: z.string(),
|
||||
clientId: z.string().optional(),
|
||||
clientSecret: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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:
|
||||
|
||||
**Universal Windows Platform (UWP)**
|
||||
**Web application**
|
||||
|
||||
- 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)
|
||||
|
||||
If your OAuth client configuration shows **Authorized redirect URIs**, add:
|
||||
Add the following redirect URI:
|
||||
|
||||
- `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:
|
||||
|
||||
- **Client ID**
|
||||
- **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"):
|
||||
|
||||
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.
|
||||
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.
|
||||
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**: 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