Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/shared-oauth-client-for-auth-users.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions packages/core/src/server/credential-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
43 changes: 36 additions & 7 deletions packages/core/src/server/credential-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -966,13 +993,15 @@ export async function resolveSecret(key: string): Promise<string | null> {
// 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}`,
Expand Down
14 changes: 12 additions & 2 deletions templates/mail/app/components/GoogleConnectBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
}
Expand Down
Loading