Skip to content
Merged
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
42 changes: 42 additions & 0 deletions apps/agui/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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<string, string> = {
// 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" },
Expand Down Expand Up @@ -268,6 +305,11 @@ export default function LoginPage() {
<p className="mt-2 text-center text-sm text-gray-600">
Select an agent and enter your credentials
</p>
{redirectNotice && (
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-md">
<p className="text-sm text-amber-700">{redirectNotice}</p>
</div>
)}
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error.message}</p>
Expand Down
120 changes: 106 additions & 14 deletions apps/agui/app/oauth/[agent]/[provider]/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -62,21 +130,30 @@ 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 {
// Get current user to link the OAuth account
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 } : {}
});
Expand All @@ -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
Expand All @@ -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);
}
};
Expand Down
29 changes: 24 additions & 5 deletions apps/agui/lib/api-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading