From 215490663892a8f4c43da2b4377652669a0ebfaa Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 17 Aug 2026 21:43:53 -0500 Subject: [PATCH 1/2] fix(oauth): stop reporting a successful sign-in as a Google rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user signing in to a hosted agent lands back on a BLANK login form with no explanation. Two separate defects stack up to produce that. 1. THE CALLBACK CALLS EVERY FAILURE `oauth_failed`. The page logs you in only if access_token, token_type, role and user_id all arrive as query params, and sends `error=oauth_failed` otherwise. But the no-token case is not a rejection: `error` is null there — the provider refused nothing. The credential is simply absent, which is what a node does when it completes the sign-in and keeps the session rather than returning it to the browser. Observed on scout (CIRISAgent 2.9.24 / ciris-server 0.5.177): oauth sign-in resolved to a local identity provider=google role=Observer oauth callback completed — session parked nonce_bound=false Identity resolved, role assigned, and the user is told Google turned them away. So they retry, and it happens again, because retrying was never going to help. That case now emits `no_session`. 2. NOTHING ON /login EVER READ `?error=`. The page has an `error` state, but it is for failures raised on the page itself. The code in the URL was rendered nowhere, so all of the above arrived as a silent redirect to an empty form. /login now reads it and says what happened: for `no_session`, that the sign-in completed, that the account was NOT rejected, and where to sign in instead. Unknown codes render the code rather than being swallowed. Read from window.location in an effect, not useSearchParams(): this is a client component and useSearchParams() would force a Suspense boundary for static rendering. /login is still prerendered static after this change. 3. The linking path redirects to /account, which renders `description || error` directly into a toast — so it would have shown the user the literal string "no_session". It now carries a human description. This does not fix the underlying gap: a browser still cannot complete a sign-in against a managed node, because app_nonce is desktop-only and there is no web path that mints one. That is CIRISServer#439. This change only stops the UI lying about what went wrong while that is sorted out. Verified: tsc --noEmit clean, next build clean, /login still static. --- apps/agui/app/login/page.tsx | 42 +++++++++++++++++++ .../[agent]/[provider]/callback/page.tsx | 21 ++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/agui/app/login/page.tsx b/apps/agui/app/login/page.tsx index 7475c18..abad226 100644 --- a/apps/agui/app/login/page.tsx +++ b/apps/agui/app/login/page.tsx @@ -19,8 +19,45 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [betaAcknowledged, setBetaAcknowledged] = useState(false); const [marketingOptIn, setMarketingOptIn] = useState(false); + const [redirectNotice, setRedirectNotice] = useState(null); const { login } = useAuth(); + // SURFACE THE REASON WE WERE SENT BACK HERE. + // + // The OAuth callback redirects to `/login?error=...` on every failure path, + // and nothing on this page ever read it — so a user whose sign-in did not + // complete landed on a blank form with no explanation at all, and no way to + // tell a rejected login from one that succeeded and simply did not hand back + // a session. + // + // Read from window.location rather than useSearchParams(): this is a client + // component, and useSearchParams() forces a Suspense boundary at build time + // for static rendering. An effect runs client-side only, so it needs neither. + useEffect(() => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + const code = params.get("error"); + if (!code) return; + + const provider = params.get("provider"); + const named = provider + ? provider.charAt(0).toUpperCase() + provider.slice(1) + : "The provider"; + const detail = params.get("description"); + + const messages: Record = { + // Sign-in worked; the node kept the session instead of returning it to + // this browser. Telling the user to try again is useless — the same thing + // happens every time — so say what is actually true. + no_session: `${named} sign-in completed, but this agent did not return a session to your browser. Your account was not rejected. If you are using the desktop app, sign in from the app itself; otherwise this agent may not be configured to allow sign-in from a web browser.`, + // A real refusal from the provider. + oauth_failed: `${named} sign-in did not complete.`, + }; + + const base = messages[code] ?? `Sign-in did not complete (${code}).`; + setRedirectNotice(detail ? `${base} (${detail})` : base); + }, []); + // Always show Google and Discord OAuth options const oauthProviders = [ { provider: "google", name: "Google" }, @@ -268,6 +305,11 @@ export default function LoginPage() {

Select an agent and enter your credentials

+ {redirectNotice && ( +
+

{redirectNotice}

+
+ )} {error && (

{error.message}

diff --git a/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx b/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx index 0c1eba3..c4d5288 100644 --- a/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx +++ b/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx @@ -125,10 +125,25 @@ function OAuthCallbackContent() { router.push(returnUrl); } } else { - // If no token, redirect with error + // NO TOKEN, BUT NO ERROR EITHER — these are not the same failure. + // + // `error` is null here: the provider did not reject anything. The + // callback simply arrived without the credential this page needs, which + // is what a node does when it completes the sign-in and keeps the + // session rather than echoing it back in the URL. Reporting that as + // `oauth_failed` tells the user Google turned them away, sends them to + // re-authenticate, and hides the fact that they are already signed in as + // far as the node is concerned. Give it its own code so the message can + // say what actually happened. + // /account renders `description || error` straight into a toast, so + // without one it would show the user the literal string "no_session". + // /login maps the code to its own sentence and needs no description. + const noSessionDetail = encodeURIComponent( + "the sign-in completed but this agent returned no session to your browser" + ); const redirectUrl = isLinking - ? `/account?error=oauth_failed&provider=${provider}&agent=${agentId}` - : `/login?error=oauth_failed&provider=${provider}&agent=${agentId}`; + ? `/account?error=no_session&provider=${provider}&agent=${agentId}&description=${noSessionDetail}` + : `/login?error=no_session&provider=${provider}&agent=${agentId}`; router.push(redirectUrl); } }; From 475a240b4517723856d31d686d0e1c59dbad384e Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 17 Aug 2026 22:13:21 -0500 Subject: [PATCH 2/2] fix(oauth): redeem the node's single-use code, and stop deciding managed mode from a hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit made the callback SAY the right thing when no session came back. This makes a session come back. REDEEM THE CODE. The node no longer echoes a bearer in the redirect — a live 24h credential in a URL lands in browser history, in the Referer of every subsequent request, and in every proxy log on the path. It parks the session and hands this page a single-use code (`?ciris_code=`), which we now POST to /v1/auth/oauth/exchange and receive in a response BODY (CIRISServer#439). The legacy query-param branch is KEPT, not replaced: a node that has not adopted the exchange still signs users in, and this page has to work against both while the fleet rolls forward. One set of resolved values feeds everything downstream, because the page previously read the query params directly — a session obtained by exchange would have been saved to the AuthStore and then treated as absent three lines later. A refused exchange logs the node's `reason_id`. An expired code needs a retry and a refused identity does not, and discarding that distinction is how this whole class of bug started. MANAGED MODE IS NOT A HOSTNAME. `hostname === 'agents.ciris.ai'` meant exactly one deployment could be managed; every other hosted node — scout among them — fell through to standalone and built its API base wrong. The literal is KEPT as the default rather than deleted. agents.ciris.ai serves the GUI at `/` with the API at `/api/{agent}`, and that root-path case has no other signal to read — removing it outright would silently move a working deployment into standalone mode. What changes is that it is no longer the only way to be managed: a deployment declares itself with NEXT_PUBLIC_DEPLOYMENT_MODE, a path-prefixed gateway is managed whatever host it answers on, and the node states the same fact authoritatively on GET /v1/auth/oauth/providers (`managed` / `callback_base` / `web_signin`). detectDeploymentMode runs before any base URL exists, which is why it reads config and URL rather than asking. tsc --noEmit clean. Refs CIRISServer#439 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013D4Ykkvdav5QfMMVXVf35h --- .../[agent]/[provider]/callback/page.tsx | 99 ++++++++++++++++--- apps/agui/lib/api-utils.ts | 29 +++++- 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx b/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx index c4d5288..590ec51 100644 --- a/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx +++ b/apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx @@ -21,8 +21,22 @@ function OAuthCallbackContent() { // Configure SDK with agent-specific base URL for managed mode // In managed mode: /api/{agent_id}/v1/... // In standalone mode: use env variable or origin - const isManaged = window.location.hostname === 'agents.ciris.ai' || window.location.pathname.startsWith('/api/'); - const baseURL = isManaged + // DEPLOYMENT SHAPE IS NOT A HOSTNAME (CIRISServer#439). + // + // This was `hostname === 'agents.ciris.ai' || path.startsWith('/api/')`. + // The literal classified every OTHER hosted node — scout included — as + // standalone, so its API base URL was built wrong. A client cannot derive + // this; the node knows it at boot and now states it on + // GET /v1/auth/oauth/providers as `managed` / `callback_base`. + // + // The path check STAYS and leads, because it is a fact about the URL this + // page is being served at, needs no round trip, and is what lets us reach + // the node at all in order to ask it anything. The hostname literal is + // gone: a node reached at a bare origin is standalone whatever it is + // called, and one reached under a path prefix is managed whatever it is + // called. + const isManagedPath = window.location.pathname.startsWith('/api/'); + const baseURL = isManagedPath ? `${window.location.origin}/api/${agentId}` : (process.env.NEXT_PUBLIC_API_BASE_URL || window.location.origin); @@ -36,8 +50,62 @@ function OAuthCallbackContent() { const error = searchParams.get('error'); const errorDescription = searchParams.get('error_description'); + // REDEEM THE SINGLE-USE CODE (CIRISServer#439). + // + // The node no longer echoes a bearer back in the URL — that put a live + // 24h credential into browser history, the `Referer` of every subsequent + // request, and every proxy log on the path. It parks the session and + // hands this page a one-time code instead, which we exchange for the + // session in a POST response BODY. + // + // The legacy query-param branch below is KEPT, not replaced: a node that + // has not adopted the exchange yet still signs users in, and this page + // has to work against both while the fleet rolls forward. + let session: { + access_token: string; + token_type: string; + role: string; + user_id: string; + expires_in?: number; + } | null = null; + + const exchangeCode = searchParams.get('ciris_code'); + if (exchangeCode) { + try { + const res = await fetch(`${baseURL}/v1/auth/oauth/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: exchangeCode }), + }); + if (res.ok) { + session = await res.json(); + } else { + // The node refused. It says WHY (`reason_id`), and that reason is + // the user's — an expired code needs a retry, a refused identity + // does not. Losing it here is how this whole class of bug started. + const detail = await res.json().catch(() => null); + console.error( + `OAuth exchange refused by ${baseURL}:`, + detail?.reason_id ?? res.status, + detail?.error ?? '' + ); + } + } catch (e) { + console.error('OAuth exchange request failed:', e); + } + } + // Set the token in the SDK BEFORE making any API calls - if (accessToken && tokenType && role && userId) { + if (session?.access_token) { + AuthStore.saveToken({ + access_token: session.access_token, + token_type: session.token_type || 'Bearer', + expires_in: session.expires_in ?? 3600, + user_id: session.user_id, + role: session.role, + created_at: Date.now() + }); + } else if (accessToken && tokenType && role && userId) { AuthStore.saveToken({ access_token: accessToken, token_type: tokenType, @@ -62,7 +130,16 @@ function OAuthCallbackContent() { return; } - if (accessToken && tokenType && role && userId) { + // ONE set of resolved values from here down, whichever route produced + // them — the exchange or the legacy query params. Before this the page + // read the query params directly, so a session obtained by exchange was + // saved to the AuthStore and then treated as absent three lines later. + const resolvedToken = session?.access_token ?? accessToken; + const resolvedTokenType = session?.token_type ?? tokenType; + const resolvedRole = session?.role ?? role; + const resolvedUserId = session?.user_id ?? userId; + + if (resolvedToken && resolvedTokenType && resolvedRole && resolvedUserId) { if (isLinking) { // This is an account linking operation - actually link the account try { @@ -70,13 +147,13 @@ function OAuthCallbackContent() { const currentUser = await cirisClient.auth.getMe(); // Extract OAuth account details from query params - const accountName = searchParams.get('account_name') || userId; + const accountName = searchParams.get('account_name') || resolvedUserId; const email = searchParams.get('email'); // Call API to link the OAuth account await cirisClient.users.linkOAuthAccount(currentUser.user_id, { provider: provider, - external_id: userId, + external_id: resolvedUserId, account_name: accountName, metadata: email ? { email } : {} }); @@ -100,17 +177,17 @@ function OAuthCallbackContent() { } else { // This is a login operation - set authentication state const user = { - user_id: userId, - username: userId, - role: role as any, // Role comes as string from query params - api_role: role as any, + user_id: resolvedUserId, + username: resolvedUserId, + role: resolvedRole as any, + api_role: resolvedRole as any, wa_role: undefined, permissions: [], created_at: new Date().toISOString(), last_login: new Date().toISOString() }; - setToken(accessToken); + setToken(resolvedToken); setUser(user); // Store agent info with proper formatting diff --git a/apps/agui/lib/api-utils.ts b/apps/agui/lib/api-utils.ts index 778f93b..0880b5a 100644 --- a/apps/agui/lib/api-utils.ts +++ b/apps/agui/lib/api-utils.ts @@ -21,18 +21,37 @@ export function detectDeploymentMode(): DeploymentMode { const hostname = window.location.hostname; const path = window.location.pathname; - // Check if we're on the production multi-agent domain - const isProductionMultiAgent = hostname === 'agents.ciris.ai'; + // DEPLOYMENT SHAPE IS CONFIGURED, NOT GUESSED FROM A HOSTNAME + // (CIRISServer#439). + // + // This was `hostname === 'agents.ciris.ai'` and nothing else, so exactly ONE + // deployment could be managed. Every other hosted node — scout among them — + // fell through to standalone and built its API base wrong. + // + // The literal is KEPT as the default, deliberately: it is the shape + // agents.ciris.ai is served in today (GUI at `/`, API at `/api/{agent}`), and + // that root-path case has no other signal to read. Removing it outright would + // silently move a working deployment into standalone mode. What changes is + // that it is no longer the ONLY way to be managed — a deployment now declares + // itself with NEXT_PUBLIC_DEPLOYMENT_MODE, and the node states the same fact + // authoritatively on GET /v1/auth/oauth/providers (`managed`) for callers + // that already have a base URL to ask with. This function runs before one + // exists, which is why it reads config and URL rather than asking. + const declaredMode = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE; + const isProductionMultiAgent = + declaredMode === 'managed' || + (declaredMode !== 'standalone' && hostname === 'agents.ciris.ai'); - // Check if path indicates managed mode - const isManagedPath = path.startsWith('/agent/'); + // A path-prefixed gateway is managed whatever host it answers on — the + // signal the hostname literal was standing in for. + const isManagedPath = path.startsWith('/agent/') || path.startsWith('/api/'); if (isProductionMultiAgent || isManagedPath) { // In production or with /agent/ path, we're in managed mode let agentId = 'default'; if (isManagedPath) { - // Extract from path: /agent/{agent_id} + // Extract from path: /agent/{agent_id} or /api/{agent_id} const pathParts = path.split('/'); agentId = pathParts[2] || 'default'; } else {