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
11 changes: 11 additions & 0 deletions agentex-ui/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ docker run --rm -p 3000:3000 \

# Or via an env file
docker run --rm -p 3000:3000 --env-file .env.local agentex-ui:latest

# With OIDC login enabled: AGENTEX_UI_AUTH_PROVIDER_ID both selects the provider AND turns login on.
docker run --rm -p 3000:3000 \
-e AGENTEX_API_URL=https://agentex.example.com \
-e AGENTEX_UI_AUTH_PROVIDER_ID=<your_provider> \
-e OIDC_ISSUER_URL=https://auth.example.com \
-e OIDC_CLIENT_ID=<your_client_id> \
-e OIDC_PRIVATE_KEY_JWK='{"kty":"EC",...}' \
-e AUTH_SECRET=change-me \
-e AUTH_URL=https://chat.example.com \
agentex-ui:latest
```

## Registry Usage
Expand Down
21 changes: 16 additions & 5 deletions agentex-ui/app/api/_lib/bff.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { authEnabled, getSessionToken } from '@/auth';

/** Server-only platform API base for the BFF routes. Prefers SGP_API_URL, else the app URL. */
export const SGP_BASE_URL =
process.env.SGP_API_URL ??
Expand All @@ -20,11 +22,10 @@ function stripCookie(header: string | null, name: string): string | null {

/**
* Attach credentials to an upstream request's `headers` in place, so none reach client JS:
* forward `x-selected-account-id` (the upstream authorizes the account), drop any
* client-sent `authorization`, and forward cookies for the upstream's own auth — minus the
* account-scoped `_jwt` (access-profile) cookie. Cookie-auth backends prioritize `_jwt` over
* `x-selected-account-id`, so leaving it would pin the account to the one the user linked in
* with and ignore the selected account; identity still comes from `_identityJwt`.
* forward `x-selected-account-id`, drop any client-sent `authorization`, then attach the
* session access token as a Bearer (auth mode) or forward cookies (default mode) — minus the
* account-scoped `_jwt`, which cookie-auth backends prioritize over the header (it would pin
* the account to the one linked in with). Identity stays via `_identityJwt`.
*/
export async function applyBffCredentials(
req: Request,
Expand All @@ -36,6 +37,16 @@ export async function applyBffCredentials(

headers.delete('authorization');

if (authEnabled) {
headers.delete('cookie');
const token = await getSessionToken(req);
if (token?.accessToken) {
headers.set('authorization', `Bearer ${token.accessToken}`);
}
return;
}

// Default mode: forward cookies (minus `_jwt`) for the upstream's own cookie auth.
const cookie = stripCookie(req.headers.get('cookie'), '_jwt');
if (cookie) headers.set('cookie', cookie);
else headers.delete('cookie');
Expand Down
7 changes: 7 additions & 0 deletions agentex-ui/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { authEnabled, handlers } from '@/auth';

// No NextAuth routes in default mode — 404 rather than the provider-less handler's 500.
const notFound = () => new Response(null, { status: 404 });

export const GET = authEnabled ? handlers.GET : notFound;
export const POST = authEnabled ? handlers.POST : notFound;
23 changes: 23 additions & 0 deletions agentex-ui/app/api/auth/auto-signin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { auth, providerId, signIn } from '@/auth';

/**
* Server-side auto sign-in: the middleware sends unauthenticated users here so NextAuth
* initiates the OIDC redirect (setting PKCE/state cookies) server-side. Single provider,
* so no picker.
*/
export async function GET(req: Request): Promise<Response> {
if (!providerId) return new Response(null, { status: 404 });
const raw = new URL(req.url).searchParams.get('redirect_url') ?? '/';
// Same-origin relative path only (open-redirect guard).
const redirectTo = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/';

// Already authenticated → skip the redundant OIDC round-trip.
const session = await auth();
if (session && !session.error) {
return Response.redirect(new URL(redirectTo, new URL(req.url).origin));
}

await signIn(providerId, { redirectTo });
// Unreachable when signIn redirects; a 500 here signals it didn't.
return new Response(null, { status: 500 });
}
26 changes: 26 additions & 0 deletions agentex-ui/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getSessionToken, oidcEndSessionEndpoint, signOut } from '@/auth';

/**
* RP-initiated (SSO) logout: clear the local session AND end the provider's SSO session,
* so middleware auto-signin doesn't silently log the user back in. The id_token is read
* server-side and never exposed on the session.
*
* POST-only: a SameSite=Lax cookie isn't sent on cross-site POSTs, so a crafted link can't
* trigger logout (GET-logout CSRF). The client POSTs, then navigates to the returned `url`.
*/
export async function POST(req: Request): Promise<Response> {
const token = await getSessionToken(req);
const idToken = token?.idToken;

await signOut({ redirect: false }); // clears the session cookie

const origin = process.env.AUTH_URL ?? new URL(req.url).origin;
const endSession = await oidcEndSessionEndpoint();
if (endSession && idToken) {
const url = new URL(endSession);
url.searchParams.set('id_token_hint', idToken);
url.searchParams.set('post_logout_redirect_uri', origin);
return Response.json({ url: url.toString() });
}
return Response.json({ url: origin });
}
39 changes: 29 additions & 10 deletions agentex-ui/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Geist, Geist_Mono } from 'next/font/google';

import { SessionProvider } from 'next-auth/react';

import { QueryProvider, ThemeProvider } from '@/components/providers';
import { SessionGuard } from '@/components/session-guard';

import type { Metadata } from 'next';
import '@/app/globals.css';
Expand All @@ -25,21 +28,37 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// Gate SessionProvider on auth: in default mode it never mounts, so the client makes
// no /api/auth/session calls.
const authEnabled = !!process.env.AGENTEX_UI_AUTH_PROVIDER_ID;
const tree = (
<QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
{children}
</ThemeProvider>
</QueryProvider>
);

return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
{children}
</ThemeProvider>
</QueryProvider>
{authEnabled ? (
// refetchInterval drives the jwt-callback refresh below the token lifetime so
// the proxy always reads a fresh token (it can't refresh on its own).
<SessionProvider refetchInterval={240}>
<SessionGuard />
{tree}
</SessionProvider>
) : (
tree
)}
</body>
</html>
);
Expand Down
17 changes: 17 additions & 0 deletions agentex-ui/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { redirect } from 'next/navigation';

/**
* Catches direct/stale hits on /login (e.g. logout or an SDK sending the browser
* to `/login?redirect_url=…`) and forwards to the server-side auto-signin handler
* so sign-in returns the user to their destination.
*/
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ redirect_url?: string }>;
}) {
const { redirect_url } = await searchParams;
redirect(
`/api/auth/auto-signin?redirect_url=${encodeURIComponent(redirect_url ?? '/')}`
);
}
7 changes: 6 additions & 1 deletion agentex-ui/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ export default async function RootPage() {
await connection();

const sgpAppURL = process.env.NEXT_PUBLIC_SGP_APP_URL ?? '';
const authEnabled = !!process.env.AGENTEX_UI_AUTH_PROVIDER_ID;
// The account picker needs the platform API (accounts come from /api/user-info).
const accountsEnabled = !!(
process.env.SGP_API_URL ?? process.env.NEXT_PUBLIC_SGP_APP_URL
);

return (
<AgentexProvider accountsEnabled={accountsEnabled} sgpAppURL={sgpAppURL}>
<AgentexProvider
authEnabled={authEnabled}
accountsEnabled={accountsEnabled}
sgpAppURL={sgpAppURL}
>
<AgentexUIRoot />
</AgentexProvider>
);
Expand Down
Loading
Loading