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..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 @@ -125,10 +202,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); } }; 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 {