From 1d5975ba255c57ab1ffacc9a7a21833e085f1d1e Mon Sep 17 00:00:00 2001 From: DIodide Date: Sat, 22 Aug 2026 15:38:08 -0400 Subject: [PATCH] Fix Google consent: pin provider clientId and read-only scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stock DO OAuth provider only learns its clientId from dynamic registration — which Google never does — and its clientId getter throws when unset, killing the authorization leg inside saveCodeVerifier before any consent URL existed. GoogleOAuthProvider now pins the pre-registered clientId in its constructor. Two more holes closed: the consent probe now also runs when the desk is already connected but tokenless (a connection left over from an earlier visit skipped the probe entirely), and the authorize URL's scope is pinned to the read-only trio — the SDK was deriving scopes from Google's resource metadata, which advertises write scopes. Verified with the real provider class against the live endpoint: auth URL produced with the three read-only scopes, access_type=offline, prompt=consent, and persisted state + verifier records for the callback. Claude-Session: https://claude.ai/code/session_01MKLJUWk6biNAKXupHTTWn5 --- app/src/server/gcal.ts | 7 +++++++ app/src/server/pi.ts | 29 ++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/src/server/gcal.ts b/app/src/server/gcal.ts index 5d7e641..2f7081c 100644 --- a/app/src/server/gcal.ts +++ b/app/src/server/gcal.ts @@ -37,6 +37,10 @@ export class GoogleOAuthProvider extends DurableObjectOAuthClientProvider { private readonly tokenStore: GcalTokenStore ) { super(storage, clientName, fixedRedirectUrl); + // The stock provider only learns its clientId from dynamic registration, + // which Google doesn't do — and its clientId getter THROWS when unset, + // killing the auth leg inside saveCodeVerifier. Pin it up front. + this.clientId = client.client_id; } override get redirectUrl(): string { @@ -72,6 +76,9 @@ export class GoogleOAuthProvider extends DurableObjectOAuthClientProvider { } override async redirectToAuthorization(authUrl: URL): Promise { + // The SDK derives scopes from the server's resource metadata, which + // advertises WRITE scopes too — pin the request to our read-only trio. + authUrl.searchParams.set("scope", GCAL_SCOPES.join(" ")); // Without offline access Google issues no refresh token and the // connection would die within the hour; prompt=consent guarantees a // refresh token on re-grants too. diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts index 815c900..6db6b81 100644 --- a/app/src/server/pi.ts +++ b/app/src/server/pi.ts @@ -167,15 +167,6 @@ export class Pi extends Think { }); if (result.state === "authenticating") { authUrls.gcal = result.authUrl; - } else if (this.isDesk() && !(await this.gcalTokensHas())) { - // Google's MCP server answers initialize and tools/list - // anonymously, so the connection lands "ready" without ever - // triggering OAuth — the 401 only appears on a real tool call. - // Force one so the SDK starts the authorization leg, then - // surface the consent URL it produced. - const url = await this.forceGcalConsent(); - if (url) authUrls.gcal = url; - else appErrors.gcal = "couldn't start Google sign-in — try again"; } continue; } @@ -191,6 +182,26 @@ export class Pi extends Think { } } + // Google's MCP server answers initialize and tools/list anonymously, so + // a tokenless connection lands "ready" without OAuth ever starting — the + // 401 only appears on a real tool call. If the desk is connected but has + // no tokens (fresh toggle OR a connection left over from an earlier + // visit), force that call and surface the consent URL it produces. + if ( + enabled.has("gcal") && + this.isDesk() && + !authUrls.gcal && + !appErrors.gcal && + this.env.GOOGLE_OAUTH_CLIENT_ID && + this.env.GOOGLE_OAUTH_CLIENT_SECRET && + this.getMcpServers().servers.gcal && + !(await this.gcalTokensHas()) + ) { + const url = await this.forceGcalConsent(); + if (url) authUrls.gcal = url; + else appErrors.gcal = "couldn't start Google sign-in — try again"; + } + this.setState({ settings, appErrors, authUrls }); return { ok: true as const, appErrors, authUrls }; }