diff --git a/agentex-ui/DOCKER.md b/agentex-ui/DOCKER.md index ff9c2b57..7e584e0c 100644 --- a/agentex-ui/DOCKER.md +++ b/agentex-ui/DOCKER.md @@ -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= \ + -e OIDC_ISSUER_URL=https://auth.example.com \ + -e OIDC_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 diff --git a/agentex-ui/app/api/_lib/bff.ts b/agentex-ui/app/api/_lib/bff.ts index dd5f7d4e..e63e4984 100644 --- a/agentex-ui/app/api/_lib/bff.ts +++ b/agentex-ui/app/api/_lib/bff.ts @@ -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 ?? @@ -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, @@ -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'); diff --git a/agentex-ui/app/api/auth/[...nextauth]/route.ts b/agentex-ui/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..b07ac345 --- /dev/null +++ b/agentex-ui/app/api/auth/[...nextauth]/route.ts @@ -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; diff --git a/agentex-ui/app/api/auth/auto-signin/route.ts b/agentex-ui/app/api/auth/auto-signin/route.ts new file mode 100644 index 00000000..733bc298 --- /dev/null +++ b/agentex-ui/app/api/auth/auto-signin/route.ts @@ -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 { + 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 }); +} diff --git a/agentex-ui/app/api/auth/logout/route.ts b/agentex-ui/app/api/auth/logout/route.ts new file mode 100644 index 00000000..e1790488 --- /dev/null +++ b/agentex-ui/app/api/auth/logout/route.ts @@ -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 { + 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 }); +} diff --git a/agentex-ui/app/layout.tsx b/agentex-ui/app/layout.tsx index be6ae760..0942116a 100644 --- a/agentex-ui/app/layout.tsx +++ b/agentex-ui/app/layout.tsx @@ -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'; @@ -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 = ( + + + {children} + + + ); + return ( - - - {children} - - + {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). + + + {tree} + + ) : ( + tree + )} ); diff --git a/agentex-ui/app/login/page.tsx b/agentex-ui/app/login/page.tsx new file mode 100644 index 00000000..4d3795d8 --- /dev/null +++ b/agentex-ui/app/login/page.tsx @@ -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 ?? '/')}` + ); +} diff --git a/agentex-ui/app/page.tsx b/agentex-ui/app/page.tsx index de8bffb3..ba12eed7 100644 --- a/agentex-ui/app/page.tsx +++ b/agentex-ui/app/page.tsx @@ -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 ( - + ); diff --git a/agentex-ui/auth.ts b/agentex-ui/auth.ts new file mode 100644 index 00000000..13c1eec8 --- /dev/null +++ b/agentex-ui/auth.ts @@ -0,0 +1,327 @@ +import NextAuth, { type NextAuthConfig } from 'next-auth'; +import { getToken } from 'next-auth/jwt'; + +/** + * Generic OIDC auth for agentex-ui — vanilla NextAuth v5, IdP is env-driven. + * + * Client auth is env-selected: OIDC_PRIVATE_KEY_JWK → private_key_jwt, else + * OIDC_CLIENT_SECRET → client_secret_post. Token + end-session endpoints come from the + * issuer's OIDC discovery document, so refresh and logout work for any compliant IdP. + * + * Route protection + auto sign-in live in middleware.ts. Env is read lazily so + * `next build` works without it. + */ + +const CLIENT_ASSERTION_TYPE = + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + +// Refresh the access token this many seconds before it actually expires, so the BFF +// proxy (which only reads the cookie, it can't refresh) never forwards a stale token. +// Paired with SessionProvider's refetchInterval, which drives the jwt callback. +const REFRESH_SKEW_S = 300; + +/** + * The provider id both selects the OIDC provider AND enables auth: AGENTEX_UI_AUTH_PROVIDER_ID + * unset = no login. Its value must equal the registered redirect_uri callback segment + * (`/api/auth/callback/`). + */ +export const providerId = process.env.AGENTEX_UI_AUTH_PROVIDER_ID; +export const authEnabled = !!providerId; + +// `| undefined` is explicit so assignments compile under exactOptionalPropertyTypes. +declare module 'next-auth' { + interface Session { + // Non-secret claims only — the access token stays in the JWT, never on the session. + error?: string | undefined; + sub?: string | undefined; + } +} +declare module '@auth/core/jwt' { + interface JWT { + accessToken?: string | undefined; + refreshToken?: string | undefined; + idToken?: string | undefined; + expiresAt?: number | undefined; + error?: string | undefined; + } +} + +function env() { + return { + issuer: process.env.OIDC_ISSUER_URL ?? '', + clientId: process.env.OIDC_CLIENT_ID ?? '', + clientSecret: process.env.OIDC_CLIENT_SECRET, + privateKeyJwk: process.env.OIDC_PRIVATE_KEY_JWK, + }; +} + +const trimSlash = (u: string) => u.replace(/\/$/, ''); + +/** + * Read the session JWT server-side. getToken defaults to the non-secure cookie name, so + * pass `secureCookie` (from AUTH_URL) to match the `__Secure-` cookie NextAuth writes over + * HTTPS — otherwise it returns null behind a TLS-terminating proxy (pod sees HTTP). + */ +export function getSessionToken(req: Request) { + return getToken({ + req, + secret: process.env.AUTH_SECRET ?? '', + secureCookie: (process.env.AUTH_URL ?? '').startsWith('https://'), + }); +} + +// Resolve the token/end-session endpoints from the issuer's well-known document so any +// IdP works. Cached per-process; a failed lookup isn't cached, so it retries. +let discoveryPromise: Promise> | null = null; + +function discoverOidc(): Promise> { + if (!discoveryPromise) { + const url = `${trimSlash(env().issuer)}/.well-known/openid-configuration`; + discoveryPromise = fetch(url) + .then(r => { + if (!r.ok) throw new Error(`discovery ${r.status}`); + return r.json() as Promise>; + }) + .then(doc => { + // token_endpoint is mandatory; a 200 without it is unusable, so throw (don't cache). + if (typeof doc.token_endpoint !== 'string' || !doc.token_endpoint) { + throw new Error('discovery missing token_endpoint'); + } + return doc; + }) + .catch(() => { + discoveryPromise = null; // don't cache a failure — retry on the next call + return {}; + }); + } + return discoveryPromise; +} + +async function oidcTokenEndpoint(): Promise { + const endpoint = (await discoverOidc()).token_endpoint; + // token_endpoint is mandatory in OIDC discovery; if it's missing the issuer is + // unreachable/misconfigured — fail the refresh so the caller re-authenticates. + if (typeof endpoint !== 'string' || !endpoint) { + throw new Error('OIDC discovery returned no token_endpoint'); + } + return endpoint; +} + +/** end_session_endpoint for RP-initiated logout; undefined if the IdP advertises none + * (in which case logout is local-only — no OP round-trip to a guessed path). */ +export async function oidcEndSessionEndpoint(): Promise { + const endpoint = (await discoverOidc()).end_session_endpoint; + return typeof endpoint === 'string' && endpoint ? endpoint : undefined; +} + +// ─── private_key_jwt helpers (WebCrypto only — OSS-clean, no deps) ─── +function base64url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function importEs256Key(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ); +} + +/** Sign an RFC 7523 client_assertion for the private_key_jwt refresh call. */ +async function signClientAssertion( + clientId: string, + tokenEndpoint: string, + jwkJson: string +): Promise { + const jwk = JSON.parse(jwkJson) as JsonWebKey & { kid?: string }; + const key = await importEs256Key(jwk); + const now = Math.floor(Date.now() / 1000); + const header = { + alg: 'ES256', + typ: 'JWT', + ...(jwk.kid ? { kid: jwk.kid } : {}), + }; + const payload = { + iss: clientId, + sub: clientId, + aud: tokenEndpoint, + jti: crypto.randomUUID(), + iat: now, + exp: now + 60, + }; + const enc = (o: unknown) => + base64url(new TextEncoder().encode(JSON.stringify(o))); + const input = `${enc(header)}.${enc(payload)}`; + const sig = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new TextEncoder().encode(input) + ); + return `${input}.${base64url(new Uint8Array(sig))}`; +} + +function buildProvider(id: string) { + const { issuer, clientId, clientSecret, privateKeyJwk } = env(); + const base = { + id, + name: process.env.AGENTEX_UI_AUTH_PROVIDER_NAME ?? id, + type: 'oidc' as const, + issuer, + clientId, + authorization: { params: { scope: 'openid profile email offline_access' } }, + checks: ['pkce', 'state'] as ('pkce' | 'state')[], + }; + if (privateKeyJwk && privateKeyJwk.trim().length > 0) { + const jwk = JSON.parse(privateKeyJwk) as JsonWebKey & { kid?: string }; + return { + ...base, + client: { token_endpoint_auth_method: 'private_key_jwt' }, + // { key, kid } so oauth4webapi adds `kid` to the login assertion (parity with the + // refresh signer). `key` is a Promise resolved before @auth/core reads it. + token: { + clientPrivateKey: { + key: importEs256Key(jwk) as unknown as CryptoKey, + ...(jwk.kid ? { kid: jwk.kid } : {}), + }, + }, + }; + } + return { + ...base, + clientSecret: clientSecret ?? '', + client: { token_endpoint_auth_method: 'client_secret_post' }, + }; +} + +function buildConfig(): NextAuthConfig { + const { clientId, clientSecret, privateKeyJwk } = env(); + const usePkJwt = !!privateKeyJwk && privateKeyJwk.trim().length > 0; + + return { + // AUTH_SECRET is auto-read from env by NextAuth v5 (and validated below). + trustHost: true, + session: { strategy: 'jwt' }, + providers: providerId ? [buildProvider(providerId)] : [], + callbacks: { + async jwt({ token, account, profile }) { + if (account) { + token.accessToken = account.access_token; + token.refreshToken = account.refresh_token; + token.idToken = account.id_token; + token.expiresAt = account.expires_at; + } + // Non-secret identity claims for the session. + if (profile) { + if (profile.sub) token.sub = profile.sub; + if (profile.name) token.name = profile.name; + if (profile.email) token.email = profile.email; + if (profile.picture) token.picture = profile.picture; + } + + const expiresAt = token.expiresAt; + if ( + expiresAt && + Date.now() / 1000 > expiresAt - REFRESH_SKEW_S && + token.refreshToken + ) { + try { + const tokenEndpoint = await oidcTokenEndpoint(); + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token.refreshToken, + client_id: clientId, + }); + + if (usePkJwt) { + body.set('client_assertion_type', CLIENT_ASSERTION_TYPE); + body.set( + 'client_assertion', + await signClientAssertion( + clientId, + tokenEndpoint, + privateKeyJwk! + ) + ); + } else { + body.set('client_secret', clientSecret ?? ''); + } + const res = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!res.ok) { + // 4xx = bad grant/client/request, won't recover by retrying → terminal, re-auth. + // 429/5xx (and the catch below: network) are transient → keep token, retry. + if (res.status >= 400 && res.status < 500 && res.status !== 429) { + token.accessToken = undefined; + token.refreshToken = undefined; + return { ...token, error: 'RefreshAccessTokenError' }; + } + return token; + } + const r = (await res.json()) as { + access_token?: string; + expires_at?: number; + expires_in?: number; + refresh_token?: string; + id_token?: string; + }; + token.accessToken = r.access_token; + token.expiresAt = + r.expires_at ?? + Math.floor(Date.now() / 1000 + (r.expires_in ?? 3600)); + if (r.refresh_token) token.refreshToken = r.refresh_token; + if (r.id_token) token.idToken = r.id_token; + } catch { + return token; // transient — keep the token, retry next cycle + } + } + return token; + }, + session({ session, token }) { + if (token.error) session.error = token.error; + session.sub = token.sub; + return session; + }, + }, + }; +} + +const config = buildConfig(); + +// Fail loudly on misconfiguration. Runs at module load (server start); `next build` +// env won't have AGENTEX_UI_AUTH_PROVIDER_ID set, so builds don't need runtime secrets. +if (authEnabled) { + const { issuer, clientId, clientSecret, privateKeyJwk } = env(); + const missing: string[] = []; + if (!issuer) missing.push('OIDC_ISSUER_URL'); + if (!clientId) missing.push('OIDC_CLIENT_ID'); + if (!clientSecret && !privateKeyJwk) { + missing.push('OIDC_CLIENT_SECRET or OIDC_PRIVATE_KEY_JWK'); + } + if (!process.env.AUTH_SECRET) missing.push('AUTH_SECRET'); + if (missing.length) { + throw new Error( + `agentex-ui: AGENTEX_UI_AUTH_PROVIDER_ID is set but required auth env is missing: ${missing.join(', ')}` + ); + } +} + +// Resolve any private_key_jwt CryptoKey into the config before constructing NextAuth. +// Config must be a static object, not a function — a config function makes `auth` async, +// so `auth((req) => …)` returns a Promise instead of callable middleware. +for (const p of config.providers ?? []) { + const pk = (p as { token?: { clientPrivateKey?: { key?: unknown } } }).token + ?.clientPrivateKey; + if (pk && pk.key instanceof Promise) { + pk.key = await pk.key; + } +} + +export const { handlers, auth, signIn, signOut } = NextAuth(config); diff --git a/agentex-ui/components/providers/agentex-provider.tsx b/agentex-ui/components/providers/agentex-provider.tsx index 5a6d4a14..6b71d1a1 100644 --- a/agentex-ui/components/providers/agentex-provider.tsx +++ b/agentex-ui/components/providers/agentex-provider.tsx @@ -17,9 +17,22 @@ import { useSafeSearchParams, } from '@/hooks/use-safe-search-params'; +// Hitting /api/auth/session runs the jwt-callback refresh and rotates the cookie. Deduped +// so a burst of 401s (e.g. a refocused tab) shares one refresh. +let sessionRefresh: Promise | null = null; +function refreshSession(): Promise { + sessionRefresh ??= fetch('/api/auth/session', { credentials: 'include' }) + .catch(() => {}) + .finally(() => { + sessionRefresh = null; + }); + return sessionRefresh; +} + interface AgentexContextValue { agentexClient: AgentexSDK; sgpAppURL: string; + authEnabled: boolean; // Platform API configured → the account picker can fetch/switch accounts. accountsEnabled: boolean; // Selected account (from the `account_id` param) + a setter that mirrors it to the URL. @@ -37,10 +50,12 @@ const AgentexContext = createContext(null); export function AgentexProvider({ children, sgpAppURL, + authEnabled, accountsEnabled, }: { children: ReactNode; sgpAppURL: string; + authEnabled: boolean; accountsEnabled: boolean; }) { const { sgpAccountID, updateParams } = useSafeSearchParams(); @@ -76,25 +91,34 @@ export function AgentexProvider({ ? `${window.location.origin}/api/agentex` : '/api/agentex'; + // Attach the selected account (from the ref — always current) on every request. + const withAccount = (init?: RequestInit): RequestInit => { + const headers = new Headers(init?.headers); + if (selectedAccountIdRef.current) { + headers.set('x-selected-account-id', selectedAccountIdRef.current); + } + return { ...init, headers }; + }; + return new AgentexSDK({ baseURL, fetchOptions: { credentials: 'include' }, - // Attach the selected account on every request (read from the ref — always current). - fetch: (input, init) => { - const headers = new Headers(init?.headers); - if (selectedAccountIdRef.current) { - headers.set('x-selected-account-id', selectedAccountIdRef.current); - } - return fetch(input, { ...init, headers }); + fetch: async (input, init) => { + const res = await fetch(input, withAccount(init)); + if (res.status !== 401 || !authEnabled) return res; + // Token expired between refreshes — refresh the session and retry once. + await refreshSession(); + return fetch(input, withAccount(init)); }, }); - }, []); + }, [authEnabled]); return ( { + if (error !== 'RefreshAccessTokenError') return; + const redirect = window.location.pathname + window.location.search; + window.location.href = `/api/auth/auto-signin?redirect_url=${encodeURIComponent(redirect)}`; + }, [error]); + + return null; +} diff --git a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx index 89b57d14..0e6d29ca 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx @@ -1,6 +1,7 @@ -import { MessageSquare } from 'lucide-react'; +import { LogOut, MessageSquare } from 'lucide-react'; import { AccountPicker } from '@/components/account-picker/account-picker'; +import { useAgentexClient } from '@/components/providers'; import { IconButton } from '@/components/ui/icon-button'; import { ResizableSidebar } from '@/components/ui/resizable-sidebar'; import { cn } from '@/lib/utils'; @@ -20,6 +21,8 @@ export function TaskSidebarFooter({ className, collapsed = false, }: TaskSidebarFooterProps) { + const { authEnabled } = useAgentexClient(); + if (collapsed) { return (
@@ -31,6 +34,7 @@ export function TaskSidebarFooter({ aria-label="Give Feedback" /> + {authEnabled && }
); } @@ -48,6 +52,49 @@ export function TaskSidebarFooter({ + {authEnabled && } ); } + +// Rendered only when auth is enabled (gated by the footer). When it is, the middleware +// guarantees an authenticated session, so this always shows — no session check, which +// would otherwise flicker in after the async session resolves. +function LogoutButton({ collapsed = false }: { collapsed?: boolean }) { + // POST (not a GET navigation) so logout can't be triggered cross-site, then follow the + // server-provided RP-initiated logout URL. See app/api/auth/logout/route.ts. + const handleLogout = async () => { + try { + const res = await fetch('/api/auth/logout', { method: 'POST' }); + const { url } = (await res.json()) as { url: string }; + window.location.href = url; + } catch { + window.location.href = '/'; + } + }; + + if (collapsed) { + return ( + + ); + } + + return ( + +
+ + Log out +
+
+ ); +} diff --git a/agentex-ui/example.env.development b/agentex-ui/example.env.development index 89ca1253..1b526f59 100644 --- a/agentex-ui/example.env.development +++ b/agentex-ui/example.env.development @@ -1,3 +1,14 @@ AGENTEX_API_URL=http://localhost:5003 # /api/agentex → agentex API # SGP_API_URL= # optional: /api/feedback & /api/user-info → SGP API # NEXT_PUBLIC_SGP_APP_URL= # optional: links to SGP traces + +#---- OIDC login (opt-in) — set AGENTEX_UI_AUTH_PROVIDER_ID to enable login. Its value must +#---- equal your IdP's registered redirect_uri callback segment (/api/auth/callback/). +# AGENTEX_UI_AUTH_PROVIDER_ID= +# AGENTEX_UI_AUTH_PROVIDER_NAME="Your Provider" # optional; defaults to the id +# AUTH_SECRET= # required; `openssl rand -base64 32` +# AUTH_URL=https://your-app.example.com # prod: pins the redirect_uri origin +# OIDC_ISSUER_URL=https://auth.example.com +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= # dev: client_secret_post +# OIDC_PRIVATE_KEY_JWK= # prod: private_key_jwt (preferred) diff --git a/agentex-ui/middleware.ts b/agentex-ui/middleware.ts new file mode 100644 index 00000000..0ffa136c --- /dev/null +++ b/agentex-ui/middleware.ts @@ -0,0 +1,37 @@ +import { + NextResponse, + type NextFetchEvent, + type NextMiddleware, + type NextRequest, +} from 'next/server'; + +import { auth, authEnabled } from '@/auth'; + +// Validate the session (the auth wrapper reassembles chunked cookies + rotates tokens in +// this cookie-writable context) and route unauthenticated users through the server-side +// auto-signin handler so the provider redirect happens server-side. +const authMiddleware = auth(req => { + if (req.auth && !req.auth.error) return; // authenticated → continue + + const signInUrl = new URL('/api/auth/auto-signin', req.nextUrl.origin); + signInUrl.searchParams.set( + 'redirect_url', + req.nextUrl.pathname + req.nextUrl.search + ); + return NextResponse.redirect(signInUrl); +}) as unknown as NextMiddleware; + +// Gated so non-auth consumers aren't forced to log in (off = original behavior). +export function middleware(request: NextRequest, event: NextFetchEvent) { + if (!authEnabled) return NextResponse.next(); + return authMiddleware(request, event); +} + +export const config = { + // Node runtime so the per-deployment AGENTEX_UI_AUTH_PROVIDER_ID is read at request + // time and the auth.js session cookie can be decrypted/rotated here. + runtime: 'nodejs', + // Gate every page; exclude api/ (BFF + NextAuth routes self-authenticate) and + // Next internals/static assets. + matcher: ['/((?!api/|_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/agentex-ui/package-lock.json b/agentex-ui/package-lock.json index 45393fe5..51893f8a 100644 --- a/agentex-ui/package-lock.json +++ b/agentex-ui/package-lock.json @@ -29,6 +29,7 @@ "framer-motion": "^12.23.24", "lucide-react": "^0.525.0", "next": "15.5.18", + "next-auth": "^5.0.0-beta.29", "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -132,6 +133,35 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true }, + "node_modules/@auth/core": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", + "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2094,6 +2124,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -7595,6 +7634,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9125,6 +9173,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.31", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz", + "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.2" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -9167,6 +9242,15 @@ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9478,6 +9562,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/agentex-ui/package.json b/agentex-ui/package.json index 2cb17a4c..012aa66d 100644 --- a/agentex-ui/package.json +++ b/agentex-ui/package.json @@ -40,6 +40,7 @@ "framer-motion": "^12.23.24", "lucide-react": "^0.525.0", "next": "15.5.18", + "next-auth": "^5.0.0-beta.29", "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1",