From 143c4f83ecf6bd611c18b6ed38524418c30164e0 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 28 May 2026 15:26:04 +0530 Subject: [PATCH] oauth migration for new scopes --- apps/x/apps/main/src/main.ts | 6 ++++ apps/x/apps/main/src/oauth-handler.ts | 46 ++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index ab026fff..81d43553 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -51,6 +51,7 @@ import { extractDeepLinkFromArgv, setMainWindowForDeepLinks, } from "./deeplink.js"; +import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; const execAsync = promisify(exec); @@ -351,6 +352,11 @@ app.whenReady().then(async () => { registerConsumer(backgroundTaskEventConsumer); initEventProcessor(); + // If the stored Google grant predates a scope change (only old scopes), + // disconnect it now so the user re-connects with the current scopes before + // any Google sync runs against the stale grant. + await disconnectGoogleIfScopesStale(); + // start gmail sync initGmailSync(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index ab00ab8c..a4ab33f7 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -508,7 +508,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b if (connection.mode === 'rowboat' && connection.tokens?.access_token) { try { const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; - const res = await fetch(revokeUrl, { method: 'POST' }); + const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); if (!res.ok) { console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`); } @@ -532,6 +532,50 @@ export async function disconnectProvider(provider: string): Promise<{ success: b } } +/** + * Startup migration for Google scope changes. When a connected Google grant was + * issued before a scope was added (e.g. old installs on gmail.readonly that + * never received gmail.modify), disconnect it so the renderer re-prompts the + * user through the normal connect flow and they re-grant with the current + * scopes. The currently-requested scopes in the provider config are the source + * of truth: a grant missing any of them is treated as stale. + * + * Tokens with no recorded scopes (very old installs that never persisted them) + * are also treated as stale. Safe to call on every startup — it's a no-op once + * the grant covers all current scopes. + */ +export async function disconnectGoogleIfScopesStale(): Promise { + try { + const oauthRepo = getOAuthRepo(); + const connection = await oauthRepo.read('google'); + + // Not connected — nothing to migrate. + if (!connection.tokens) { + return; + } + + const providerConfig = await getProviderConfig('google'); + const requiredScopes = providerConfig.scopes ?? []; + if (requiredScopes.length === 0) { + return; + } + + const granted = new Set(connection.tokens.scopes ?? []); + const missingScopes = requiredScopes.filter((scope) => !granted.has(scope)); + if (missingScopes.length === 0) { + return; + } + + console.log( + `[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` + + 'disconnecting so the user can reconnect with the new scopes.' + ); + await disconnectProvider('google'); + } catch (error) { + console.error('[OAuth] Google scope migration check failed:', error); + } +} + /** * Get access token for a provider (internal use only) * Refreshes token if expired