diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 1048d9b8..4fe5d8d0 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -420,15 +420,23 @@ export async function connectProvider(provider: string, credentials?: { clientId scope: scopes.join(' '), code_challenge: codeChallenge, state, + // Google only returns a refresh_token when offline access is requested, + // and only re-issues one when re-consent is forced. Without these, a + // BYOK token expires after ~1h with no way to refresh (it goes stale and + // every Google call — including the Picker — starts failing). + ...(provider === 'google' ? { access_type: 'offline', prompt: 'consent' } : {}), }); - // Set timeout to clean up abandoned flows (2 minutes) + // Set timeout to clean up abandoned flows. Generous (10 min) because a + // first-time connect can involve creating/locating OAuth credentials in + // the Cloud Console mid-flow; a short window tears down the callback + // server before the user finishes consent, silently dropping the token. const cleanupTimeout = setTimeout(() => { if (activeFlow?.state === state) { console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); cancelActiveFlow('timed_out'); } - }, 2 * 60 * 1000); + }, 10 * 60 * 1000); activeFlow = { provider, diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index e169bb86..a2268ede 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -200,8 +200,16 @@ export async function getGoogleDocsConnectionStatus(): Promise<{ * Picker (file selection happens client-side; the app never lists Drive). */ export async function getGoogleAccessToken(): Promise { + // getClient() refreshes an expired token when it can. If it returns a token + // that's still past expiry, the refresh failed (e.g. no refresh_token) — hand + // back null rather than a dead token, so the UI prompts a reconnect instead + // of silently passing an expired token to the Picker (which 403s on it). const auth = await GoogleClientFactory.getClient(); - return auth?.credentials?.access_token ?? null; + const token = auth?.credentials?.access_token ?? null; + if (!token) return null; + const expiry = auth?.credentials?.expiry_date; + if (typeof expiry === 'number' && expiry <= Date.now()) return null; + return token; } /** Import a Google Doc as a local .docx and register the link. */