From af75fa2b5fca49dba0bb6958fe84b5c1ba78329a Mon Sep 17 00:00:00 2001 From: Aryeh Stark Date: Tue, 30 Jun 2026 23:04:58 +0300 Subject: [PATCH 1/3] Fix infinite sign-in redirect loop under base-path deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequireSession had no guard against redirecting to the sign-in page from the sign-in page. When the app shell is served at the sign-in path (e.g. a base-path deploy where the SPA answers //_agent-native/sign-in with no client- resolvable session), it redirected sign-in -> sign-in, nesting and re-encoding the current URL as a fresh ?return= on every hop (the runaway .../sign-in?return=%252F...sign-in URL). - RequireSession: new isOnSignInPage() guard — never redirect to sign-in when already on it. - safeReturnPath: collapse any return that resolves back to .../_agent-native/sign-in to "/" (server-side defense in depth). Adds tests for both paths and a changeset. --- .changeset/signin-redirect-loop-guard.md | 12 +++++++++ .../core/src/client/require-session.spec.tsx | 26 +++++++++++++++++++ packages/core/src/client/require-session.tsx | 21 ++++++++++++++- packages/core/src/server/auth.spec.ts | 21 +++++++++++++++ packages/core/src/server/auth.ts | 6 +++++ 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .changeset/signin-redirect-loop-guard.md diff --git a/.changeset/signin-redirect-loop-guard.md b/.changeset/signin-redirect-loop-guard.md new file mode 100644 index 0000000000..910c92670b --- /dev/null +++ b/.changeset/signin-redirect-loop-guard.md @@ -0,0 +1,12 @@ +--- +"@agent-native/core": patch +--- + +Fix an infinite sign-in redirect loop under base-path deploys. When the +authenticated app shell (wrapped in `RequireSession`) was served at the sign-in +path — e.g. `//_agent-native/sign-in` on a path-prefixed deploy — the gate +redirected to the sign-in page from the sign-in page, nesting and re-encoding the +current URL as a fresh `?return=` on every hop (`…sign-in?return=%252F…sign-in%253Freturn…`). +`RequireSession` now refuses to redirect when already on the sign-in entry point +(new exported `isOnSignInPage` helper), and `safeReturnPath` collapses any +`return` that resolves back to `…/_agent-native/sign-in` to `/`. diff --git a/packages/core/src/client/require-session.spec.tsx b/packages/core/src/client/require-session.spec.tsx index ff6cf5f5e7..cc87b17111 100644 --- a/packages/core/src/client/require-session.spec.tsx +++ b/packages/core/src/client/require-session.spec.tsx @@ -98,6 +98,32 @@ describe("RequireSession", () => { expect(href).toContain(encodeURIComponent("/inbox?label=important")); }); + it("never redirects when already on the sign-in page (no infinite loop)", () => { + // Simulates the base-path deploy case where the app shell is served at the + // sign-in path. Redirecting here would nest the sign-in URL as a fresh + // `?return=` and loop forever — the gate must not redirect to itself. + Object.defineProperty(window, "location", { + configurable: true, + value: { + pathname: "/_agent-native/sign-in", + search: "?return=%2Finbox", + hash: "", + origin: "https://mail.example.com", + href: "https://mail.example.com/_agent-native/sign-in?return=%2Finbox", + replace: replaceMock, + assign: vi.fn(), + }, + }); + useSessionMock.mockReturnValue({ session: null, isLoading: false }); + render( + + + , + ); + expect(replaceMock).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="protected"]')).toBeNull(); + }); + it("does not redirect twice across re-renders", () => { useSessionMock.mockReturnValue({ session: null, isLoading: false }); render( diff --git a/packages/core/src/client/require-session.tsx b/packages/core/src/client/require-session.tsx index d2997105cb..4cc2e8658f 100644 --- a/packages/core/src/client/require-session.tsx +++ b/packages/core/src/client/require-session.tsx @@ -70,6 +70,24 @@ export function buildSignInReturnHref(): string { return `${base}?return=${encodeURIComponent(ret)}`; } +/** + * True when the browser is already sitting on the framework sign-in entry + * point. Redirecting to sign-in from here would append the current sign-in + * URL as a fresh `?return=`, re-encode it, and navigate again — an infinite + * loop of ever-growing `…sign-in?return=%252F…sign-in%253Freturn%253D…` URLs. + * + * This happens when the authenticated app shell (wrapped in `RequireSession`) + * is served at the sign-in path instead of the framework login HTML — e.g. + * under a base-path deploy where the SPA shell answers `//_agent-native/ + * sign-in`. The sign-in page is the framework's job, not the gate's: never + * redirect to ourselves. Compared against `agentNativePath(...)` so it matches + * whether or not the app is mounted under a base path. + */ +export function isOnSignInPage(): boolean { + if (typeof window === "undefined") return false; + return window.location.pathname === agentNativePath("/_agent-native/sign-in"); +} + export function RequireSession({ children, fallback, @@ -83,7 +101,8 @@ export function RequireSession({ // flight is harmless but noisy. const redirectedRef = useRef(false); - const mustRedirect = !bypass && !isLoading && !session && redirect; + const mustRedirect = + !bypass && !isLoading && !session && redirect && !isOnSignInPage(); useEffect(() => { if (!mustRedirect) return; diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index 39d526890b..246b33ff33 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -2855,6 +2855,27 @@ describe("server/auth", () => { // the parsed segments.) expect(safeReturnPath("/foo?bar=1#baz")).toBe("/foo?bar=1#baz"); }); + + it("collapses a return that points back at the sign-in page (loop guard)", async () => { + const safeReturnPath = await load(); + // A `return` resolving to the sign-in entry point would re-enter the + // redirect loop — collapse to "/". Covers root and base-path mounts, + // and a nested already-encoded loop URL. + expect(safeReturnPath("/_agent-native/sign-in")).toBe("/"); + expect(safeReturnPath("/_agent-native/sign-in?return=%2Finbox")).toBe( + "/", + ); + expect(safeReturnPath("/mail/_agent-native/sign-in")).toBe("/"); + expect( + safeReturnPath( + "/mail/_agent-native/sign-in?return=%252Fmail%252F_agent-native%252Fsign-in", + ), + ).toBe("/"); + // A normal app path that merely contains the words is unaffected. + expect(safeReturnPath("/mail/inbox?label=important")).toBe( + "/mail/inbox?label=important", + ); + }); }); describe("OAuth return URLs", () => { diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index 05ab94b480..528344d8a1 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -542,6 +542,12 @@ export function safeReturnPath(raw: string | null | undefined): string { try { const parsed = new URL(raw, "http://safe-base.invalid"); if (parsed.origin !== "http://safe-base.invalid") return "/"; + // Never return to the sign-in entry point itself. A `return` that resolves + // back to `…/_agent-native/sign-in` re-enters the redirect loop — each hop + // nests the prior sign-in URL as a fresh `?return=`. Collapse it to "/" so + // the post-sign-in 302 lands on the app, not another sign-in page. Matches + // with or without an app base-path prefix (`//_agent-native/sign-in`). + if (parsed.pathname.endsWith("/_agent-native/sign-in")) return "/"; return parsed.pathname + parsed.search + parsed.hash; } catch { return "/"; From 5bb0bebb6696119063b71ff7c79ed08e8e1c0aa0 Mon Sep 17 00:00:00 2001 From: Aryeh Stark Date: Wed, 1 Jul 2026 00:51:33 +0300 Subject: [PATCH 2/3] 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); } From bd01ac2241a073367e4f3301f35b7c5cd6d9128b Mon Sep 17 00:00:00 2001 From: Aryeh Stark Date: Wed, 1 Jul 2026 06:52:40 +0300 Subject: [PATCH 3/3] Fix org app-switcher linking to the official site on custom deployments defaultOrgAppLinks hardcoded each template's *.agent-native.com prod URL, so a path-prefixed deployment (served at //, e.g. apps.example.com/mail/) linked every sibling app to the first-party hosted site instead of the current deployment. Build // links when the client is path-prefixed (VITE_APP_BASE_PATH baked at build); keep the prod URLs for the subdomain layout. --- .changeset/org-switcher-same-origin-links.md | 10 +++++ .../client/org/workspace-app-links.spec.ts | 32 ++++++++++++++ .../src/client/org/workspace-app-links.ts | 42 ++++++++++++------- 3 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 .changeset/org-switcher-same-origin-links.md diff --git a/.changeset/org-switcher-same-origin-links.md b/.changeset/org-switcher-same-origin-links.md new file mode 100644 index 0000000000..9d0bd25843 --- /dev/null +++ b/.changeset/org-switcher-same-origin-links.md @@ -0,0 +1,10 @@ +--- +"@agent-native/core": patch +--- + +Fix the org app-switcher escaping to the official *.agent-native.com site on +custom deployments. `defaultOrgAppLinks` hardcoded each template's prod URL, so a +path-prefixed deployment (served at `//`, which bakes +`VITE_APP_BASE_PATH`) linked sibling apps to the first-party hosted site instead +of the current deployment. It now builds `//` links when the app is +path-prefixed, and keeps the prod URLs for the first-party subdomain layout. diff --git a/packages/core/src/client/org/workspace-app-links.spec.ts b/packages/core/src/client/org/workspace-app-links.spec.ts index 31eb8a6274..1dca033fcc 100644 --- a/packages/core/src/client/org/workspace-app-links.spec.ts +++ b/packages/core/src/client/org/workspace-app-links.spec.ts @@ -41,6 +41,38 @@ describe("org switcher app links", () => { expect(apps.map((app) => app.id)).not.toContain("videos"); }); + it("uses this origin under // for a path-prefixed deployment", () => { + const prevWindow = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = { + location: { origin: "https://apps.evinced.tech" }, + }; + try { + // VITE_APP_BASE_PATH is baked by a path-prefixed deploy — links must stay + // on this origin, not escape to *.agent-native.com. + const apps = defaultOrgAppLinks({ VITE_APP_BASE_PATH: "/mail" }); + expect(apps.find((a) => a.id === "mail")?.href).toBe( + "https://apps.evinced.tech/mail", + ); + expect(apps.find((a) => a.id === "dispatch")?.href).toBe( + "https://apps.evinced.tech/dispatch/overview", + ); + expect(apps.every((a) => !a.href.includes("agent-native.com"))).toBe( + true, + ); + } finally { + (globalThis as { window?: unknown }).window = prevWindow; + } + }); + + it("keeps the first-party *.agent-native.com URLs when not path-prefixed", () => { + // No VITE_APP_BASE_PATH → the app is served at its own root (subdomain + // layout); keep the prod URLs. + const apps = defaultOrgAppLinks({}); + expect(apps.find((a) => a.id === "mail")?.href).toBe( + "https://mail.agent-native.com", + ); + }); + it("normalizes workspace app manifests against the workspace gateway", () => { const apps = parseWorkspaceAppLinks( { diff --git a/packages/core/src/client/org/workspace-app-links.ts b/packages/core/src/client/org/workspace-app-links.ts index 76d5ed060b..445d463a10 100644 --- a/packages/core/src/client/org/workspace-app-links.ts +++ b/packages/core/src/client/org/workspace-app-links.ts @@ -206,22 +206,36 @@ export function parseWorkspaceAppLinksJson( } } -export function defaultOrgAppLinks(): OrgSwitcherAppLink[] { +export function defaultOrgAppLinks( + env: RuntimeEnv = runtimeEnv(), +): OrgSwitcherAppLink[] { + // A path-prefixed deployment (e.g. apps.example.com//) bakes + // VITE_APP_BASE_PATH=/ into the client at build time. There, the sibling + // apps live on THIS origin under // — using the hardcoded + // *.agent-native.com prod URLs would send users off to the official hosted + // site instead of the current deployment. Fall back to prodUrl only when the + // app is served at its own root (the first-party agent-native.com layout). + const pathPrefixed = Boolean(envString(env, "VITE_APP_BASE_PATH")); + const origin = + pathPrefixed && typeof window !== "undefined" + ? window.location.origin + : null; return sortAppLinks( coreTemplates() .filter((template) => !template.hidden && template.prodUrl) - .map((template) => ({ - id: template.name, - name: template.label, - href: - template.name === DISPATCH_ID - ? appendPath(template.prodUrl!, "overview") - : template.prodUrl!, - description: template.hint, - icon: template.icon, - isDispatch: template.name === DISPATCH_ID, - status: "ready" as const, - })), + .map((template) => { + const base = origin ? `${origin}/${template.name}` : template.prodUrl!; + return { + id: template.name, + name: template.label, + href: + template.name === DISPATCH_ID ? appendPath(base, "overview") : base, + description: template.hint, + icon: template.icon, + isDispatch: template.name === DISPATCH_ID, + status: "ready" as const, + }; + }), ); } @@ -318,7 +332,7 @@ export function useOrgSwitcherAppLinks( const env = useMemo(() => runtimeEnv(), []); const isWorkspace = useMemo(() => isWorkspaceAppEnvironment(env), [env]); const [apps, setApps] = useState(() => - isWorkspace ? initialWorkspaceLinks(env) : defaultOrgAppLinks(), + isWorkspace ? initialWorkspaceLinks(env) : defaultOrgAppLinks(env), ); const [isLoading, setIsLoading] = useState(false);