From 1f4dc353eabd82dc60f34b886d908f8ed7a1e471 Mon Sep 17 00:00:00 2001 From: Aryeh Stark Date: Wed, 1 Jul 2026 00:51:33 +0300 Subject: [PATCH] Allow a shared deployment OAuth client for authenticated users OAuth client credentials (GOOGLE_CLIENT_ID/SECRET, GOOGLE_SIGN_IN_CLIENT_*, GITHUB_CLIENT_*) are deployment-wide identity, not per-tenant secrets: every user authorizes the same app for their own account and gets their own tokens. But resolveSecret refused to fall back to the deploy env for signed-in users in a hosted runtime, so a deployment that set GOOGLE_CLIENT_* still showed each logged-in user the "bring your own OAuth app" setup wizard and /google/auth-url returned missing_credentials. Let these keys use the deploy env fallback (same as Builder credential keys); a per-tenant BYO client still wins (resolved first). Also fix GoogleConnectBanner computing "configured" across every env key (Slack/Resend/LLMs) instead of just the Google client keys. --- .../shared-oauth-client-for-auth-users.md | 13 ++++++ .../src/server/credential-provider.spec.ts | 42 ++++++++++++++++++ .../core/src/server/credential-provider.ts | 43 ++++++++++++++++--- .../app/components/GoogleConnectBanner.tsx | 14 +++++- 4 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 .changeset/shared-oauth-client-for-auth-users.md diff --git a/.changeset/shared-oauth-client-for-auth-users.md b/.changeset/shared-oauth-client-for-auth-users.md new file mode 100644 index 0000000000..36eab0bb79 --- /dev/null +++ b/.changeset/shared-oauth-client-for-auth-users.md @@ -0,0 +1,13 @@ +--- +"@agent-native/core": patch +--- + +Allow a shared deployment OAuth client for authenticated users. OAuth *client* +credentials (`GOOGLE_CLIENT_ID/SECRET`, `GOOGLE_SIGN_IN_CLIENT_*`, +`GITHUB_CLIENT_*`) are deployment-wide identity, not per-tenant secrets — every +user authorizes the same app for their own account and gets their own tokens. +`resolveSecret` now lets these keys fall back to the deploy env even for +signed-in users in a hosted runtime (the same treatment Builder credential keys +get), so one deployment can offer a single "Sign in with Google" instead of +forcing every user to register their own Google Cloud OAuth app. A per-tenant +client (BYO upload) still wins — user/org/workspace scopes are resolved first. diff --git a/packages/core/src/server/credential-provider.spec.ts b/packages/core/src/server/credential-provider.spec.ts index 52b784d6f9..d9788bf795 100644 --- a/packages/core/src/server/credential-provider.spec.ts +++ b/packages/core/src/server/credential-provider.spec.ts @@ -865,4 +865,46 @@ describe("resolveSecret (generic)", () => { expect(await resolveSecret("SOME_KEY")).toBe("v"); delete process.env.SOME_KEY; }); + + it("uses the deploy env for OAuth client keys in an authenticated production request (shared deployment client)", async () => { + process.env.NODE_ENV = "production"; + process.env.GOOGLE_CLIENT_ID = "deploy-google-client-id"; + process.env.GOOGLE_CLIENT_SECRET = "deploy-google-client-secret"; + process.env.OPENAI_API_KEY = "deploy-openai-key"; + mockIsLocalDatabase.mockReturnValue(false); + mockGetRequestUserEmail.mockReturnValue("a@b.com"); + mockGetRequestOrgId.mockReturnValue(undefined); + mockReadAppSecret.mockResolvedValue(null); + // A shared OAuth client is deployment-wide identity, so the deploy env is a + // safe fallback for signed-in users (each still does their own OAuth). + expect(await resolveSecret("GOOGLE_CLIENT_ID")).toBe( + "deploy-google-client-id", + ); + expect(await resolveSecret("GOOGLE_CLIENT_SECRET")).toBe( + "deploy-google-client-secret", + ); + // A per-tenant API key stays blocked from the deploy env. + expect(await resolveSecret("OPENAI_API_KEY")).toBeNull(); + delete process.env.GOOGLE_CLIENT_ID; + delete process.env.GOOGLE_CLIENT_SECRET; + }); + + it("lets a per-tenant OAuth client override the deploy env (BYO wins)", async () => { + process.env.NODE_ENV = "production"; + process.env.GOOGLE_CLIENT_ID = "deploy-google-client-id"; + mockIsLocalDatabase.mockReturnValue(false); + mockGetRequestUserEmail.mockReturnValue("a@b.com"); + mockGetRequestOrgId.mockReturnValue("org_1"); + mockReadAppSecret + .mockResolvedValueOnce(null) // user scope miss + .mockResolvedValueOnce({ + value: "tenant-google-client-id", + last4: "t-id", + updatedAt: 1, + }); // org scope hit — returned before the env fallback + expect(await resolveSecret("GOOGLE_CLIENT_ID")).toBe( + "tenant-google-client-id", + ); + delete process.env.GOOGLE_CLIENT_ID; + }); }); diff --git a/packages/core/src/server/credential-provider.ts b/packages/core/src/server/credential-provider.ts index ba1bbd9a67..69b1668a6d 100644 --- a/packages/core/src/server/credential-provider.ts +++ b/packages/core/src/server/credential-provider.ts @@ -108,6 +108,33 @@ function isBuilderCredentialKey(key: string): boolean { return (BUILDER_CREDENTIAL_KEYS as readonly string[]).includes(key); } +/** + * OAuth *client* credentials — the client_id / client_secret that identify the + * deployment's own OAuth app (Google / GitHub sign-in, Gmail / Calendar / Docs + * connect). Unlike a per-tenant API key, a shared OAuth client is deployment- + * wide identity, not a secret that belongs to one tenant: every user authorizes + * the SAME app for their OWN account and receives their OWN tokens (stored + * per-user in `oauth_tokens`), so a deploy-level value can never impersonate + * another tenant. A tenant that supplies its own client (the BYO upload path) + * still wins — user/org/workspace scopes are read first, above this env + * fallback. So these keys are allowed to fall back to the deploy env even for + * authenticated hosted users (the same treatment Builder credential keys get), + * which lets one deployment offer a single shared "Sign in with Google" instead + * of forcing every user to register their own Google Cloud OAuth app. + */ +const DEPLOYMENT_WIDE_OAUTH_CLIENT_KEYS = [ + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "GOOGLE_SIGN_IN_CLIENT_ID", + "GOOGLE_SIGN_IN_CLIENT_SECRET", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", +] as const; + +function isDeploymentWideOAuthClientKey(key: string): boolean { + return (DEPLOYMENT_WIDE_OAUTH_CLIENT_KEYS as readonly string[]).includes(key); +} + function isHostedWorkspaceRuntime(): boolean { const hasFusionPreview = Boolean( process.env.FUSION_ENVIRONMENT || @@ -966,13 +993,15 @@ export async function resolveSecret(key: string): Promise { // The deploy-level value would silently impersonate the actual key // owner across every tenant. Local/single-tenant deployments keep the // original env fallback for BYO-server workflows. - const envFallback = ( - isBuilderCredentialKey(key) - ? canUseBuilderDeployCredentialFallbackForRequest() - : canUseDeployCredentialFallbackForRequest() - ) - ? process.env[key] || null - : null; + const allowDeployFallback = isBuilderCredentialKey(key) + ? canUseBuilderDeployCredentialFallbackForRequest() + : isDeploymentWideOAuthClientKey(key) + ? // A shared OAuth client is deployment-wide identity, not a per-tenant + // secret — safe to use for authenticated users (BYO per-tenant client + // is still resolved first, above). See DEPLOYMENT_WIDE_OAUTH_CLIENT_KEYS. + true + : canUseDeployCredentialFallbackForRequest(); + const envFallback = allowDeployFallback ? process.env[key] || null : null; if (traceLookup) { console.log( `[resolve-secret] key=${key} email=${email} orgId=${getRequestOrgId() ?? "(none)"} scope=${envFallback ? "env-fallback" : "none"} hit=${!!envFallback}`, diff --git a/templates/mail/app/components/GoogleConnectBanner.tsx b/templates/mail/app/components/GoogleConnectBanner.tsx index 98ef7db610..9234466299 100644 --- a/templates/mail/app/components/GoogleConnectBanner.tsx +++ b/templates/mail/app/components/GoogleConnectBanner.tsx @@ -33,6 +33,17 @@ interface EnvKeyStatus { configured: boolean; } +// Only the Google OAuth client keys decide whether Google is set up. The +// env-status endpoint returns every registered key (Slack, Resend, all the LLM +// providers, …); requiring all of them to be configured meant Google was never +// recognized as ready, so the setup wizard showed even with valid creds. +const GOOGLE_CRED_KEYS = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"]; + +function googleCredsConfigured(keys: EnvKeyStatus[]): boolean { + const googleKeys = keys.filter((k) => GOOGLE_CRED_KEYS.includes(k.key)); + return googleKeys.length > 0 && googleKeys.every((k) => k.configured); +} + const STEPS = [ { titleKey: "mail.googleConnect.enableGmailApi", @@ -185,8 +196,7 @@ export function GoogleConnectBanner({ if (res.ok) { const data: EnvKeyStatus[] = await res.json(); setEnvStatus(data); - const allConfigured = data.every((k) => k.configured); - if (allConfigured && data.length > 0) { + if (googleCredsConfigured(data)) { setSaved(true); setCurrentStep(STEPS.length - 1); }