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

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

Key changes:

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

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

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
);
}

View file

@ -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(),
});

View file

@ -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,7 +170,8 @@ 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
@ -191,22 +199,23 @@ export class GoogleClientFactory {
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
);

View file

@ -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(),

View file

@ -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**
![Create OAuth Client ID (UWP)](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png)
### Authorized redirect URIs
### Authorized redirect URIs (if shown)
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.
![Copy Client ID](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/06-copy-client-id.png)
![Enter credentials in Rowboat](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/07-enter-credentials.png)
---
@ -152,7 +150,7 @@ Copy the **Client ID** and paste it into Rowboat where prompted.
If the browser shows "Authorization Successful" but the app then shows an error (e.g. "invalid response encountered" or "response parameter \"iss\" (issuer) missing"):
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.
---