From b4d9354c91198d2742b953d69357145b44d5e602 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 14:32:38 -0400 Subject: [PATCH 1/6] feat(agentex-ui): OIDC login with server-side access token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in OIDC login on top of the BFF, keeping the access token out of the browser entirely. - NextAuth (generic OIDC provider) selected + enabled by a single env var, `AGENTEX_UI_AUTH_PROVIDER_ID`; disabled by default so non-auth deployments are unaffected (no SessionProvider mounts, no /api/auth/session calls). - The BFF attaches the access token as a Bearer server-side via `getToken` and drops the UI session cookie — the token never reaches client JS or the NextAuth session (which exposes only `error`). - Middleware auto-redirects unauthenticated requests to sign-in; a client guard re-auths on refresh failure; RP-initiated logout clears the IdP session. - Supports `client_secret_post` (dev) and `private_key_jwt` (prod). Co-Authored-By: Claude Opus 4.8 --- agentex-ui/DOCKER.md | 11 + agentex-ui/app/api/_lib/bff.ts | 25 +- .../app/api/auth/[...nextauth]/route.ts | 3 + agentex-ui/app/api/auth/auto-signin/route.ts | 18 ++ agentex-ui/app/api/auth/logout/route.ts | 30 +++ agentex-ui/app/layout.tsx | 39 ++- agentex-ui/app/login/page.tsx | 17 ++ agentex-ui/app/page.tsx | 7 +- agentex-ui/auth.ts | 255 ++++++++++++++++++ .../components/providers/agentex-provider.tsx | 4 + agentex-ui/components/session-guard.tsx | 25 ++ .../task-sidebar/task-sidebar-footer.tsx | 41 ++- agentex-ui/example.env.development | 11 + agentex-ui/middleware.ts | 39 +++ agentex-ui/package-lock.json | 103 +++++++ agentex-ui/package.json | 1 + 16 files changed, 612 insertions(+), 17 deletions(-) create mode 100644 agentex-ui/app/api/auth/[...nextauth]/route.ts create mode 100644 agentex-ui/app/api/auth/auto-signin/route.ts create mode 100644 agentex-ui/app/api/auth/logout/route.ts create mode 100644 agentex-ui/app/login/page.tsx create mode 100644 agentex-ui/auth.ts create mode 100644 agentex-ui/components/session-guard.tsx create mode 100644 agentex-ui/middleware.ts 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..84862e27 100644 --- a/agentex-ui/app/api/_lib/bff.ts +++ b/agentex-ui/app/api/_lib/bff.ts @@ -1,3 +1,7 @@ +import { getToken } from 'next-auth/jwt'; + +import { authEnabled } 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 +24,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 +39,18 @@ export async function applyBffCredentials( headers.delete('authorization'); + if (authEnabled) { + headers.delete('cookie'); + const token = await getToken({ + req: req as never, + secret: process.env.AUTH_SECRET ?? '', + }); + const accessToken = token?.accessToken as string | undefined; + if (accessToken) headers.set('authorization', `Bearer ${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..0a98352f --- /dev/null +++ b/agentex-ui/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth'; + +export const { GET, POST } = handlers; 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..518227a3 --- /dev/null +++ b/agentex-ui/app/api/auth/auto-signin/route.ts @@ -0,0 +1,18 @@ +import { providerId, signIn } from '@/auth'; + +/** + * Server-side auto sign-in: middleware sends unauthenticated users here so the + * OIDC redirect to the provider is initiated server-side (NextAuth sets the + * PKCE/state cookies + performs the redirect). Single provider, so no picker page. + * + * Relies on `signIn` performing the redirect (throwing NEXT_REDIRECT) in a GET + * route handler; if it returns a URL instead, use + * `return Response.redirect(await signIn(providerId, { redirectTo, redirect: false }))`. + */ +export async function GET(req: Request): Promise { + if (!providerId) return new Response(null, { status: 404 }); + const redirectTo = new URL(req.url).searchParams.get('redirect_url') ?? '/'; + 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..cc85fc09 --- /dev/null +++ b/agentex-ui/app/api/auth/logout/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; + +import { getToken } from 'next-auth/jwt'; + +import { signOut } from '@/auth'; + +/** + * RP-initiated (SSO) logout: clear the local NextAuth session AND end the provider's + * SSO (OP) session, so middleware auto-signin doesn't silently log the user back in. + * The id_token is read server-side (getToken) and never exposed on the session. + */ +export async function GET(req: Request): Promise { + const token = await getToken({ + req: req as never, + secret: process.env.AUTH_SECRET ?? '', + }); + const idToken = token?.idToken as string | undefined; + + await signOut({ redirect: false }); // clears the session cookie + + const origin = process.env.AUTH_URL ?? new URL(req.url).origin; + const issuer = process.env.OIDC_ISSUER_URL; + if (issuer && idToken) { + const url = new URL(`${issuer}/oauth2/sessions/logout`); + url.searchParams.set('id_token_hint', idToken); + url.searchParams.set('post_logout_redirect_uri', origin); + return NextResponse.redirect(url); + } + return NextResponse.redirect(origin); +} diff --git a/agentex-ui/app/layout.tsx b/agentex-ui/app/layout.tsx index be6ae760..76ac6224 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 NextAuth's SessionProvider on auth: in direct (legacy) 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..300f875e --- /dev/null +++ b/agentex-ui/auth.ts @@ -0,0 +1,255 @@ +import NextAuth, { type NextAuthConfig } from 'next-auth'; + +/** + * Generic OIDC auth for agentex-ui — vanilla NextAuth v5, the IdP is fully env-driven. + * + * Client authentication is env-selected: + * - OIDC_PRIVATE_KEY_JWK set → private_key_jwt (matches deployments whose client + * registration is private_key_jwt-only) + * - else OIDC_CLIENT_SECRET → client_secret_post (convenient for local dev) + * + * Route protection + auto sign-in live in middleware.ts (which redirects to the + * server-side /api/auth/auto-signin handler) — a standard BFF pattern. Env is read + * lazily so `next build` works without env present. + */ + +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: set + * AGENTEX_UI_AUTH_PROVIDER_ID to turn login on (unset = original direct behavior). + * Its value must equal the callback segment registered as the IdP redirect_uri + * (`/api/auth/callback/`) — e.g. `oneauth`. + */ +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 { + // BFF: the access token is NOT exposed on the session (stays in the JWT/cookie). + error?: 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, + }; +} + +// ─── 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) { + return { + ...base, + client: { token_endpoint_auth_method: 'private_key_jwt' }, + // Promise — resolved before @auth/core reads it (see export). + token: { + clientPrivateKey: importEs256Key( + JSON.parse(privateKeyJwk) + ) as unknown as CryptoKey, + }, + }; + } + return { + ...base, + clientSecret: clientSecret ?? '', + client: { token_endpoint_auth_method: 'client_secret_post' }, + }; +} + +function buildConfig(): NextAuthConfig { + const { issuer, clientId, clientSecret, privateKeyJwk } = env(); + const tokenEndpoint = `${issuer}/oauth2/token`; + 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 }) { + if (account) { + token.accessToken = account.access_token; + token.refreshToken = account.refresh_token; + token.idToken = account.id_token; + token.expiresAt = account.expires_at; + } + const expiresAt = token.expiresAt; + if ( + expiresAt && + Date.now() / 1000 > expiresAt - REFRESH_SKEW_S && + token.refreshToken + ) { + try { + 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) { + token.accessToken = undefined; + token.refreshToken = undefined; + return { ...token, error: 'RefreshAccessTokenError' }; + } + 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 { + token.accessToken = undefined; + token.refreshToken = undefined; + return { ...token, error: 'RefreshAccessTokenError' }; + } + } + return token; + }, + session({ session, token }) { + // BFF: keep the access token server-side (JWT/cookie); never on session. + // The /api/agentex proxy reads it via getToken() and attaches the Bearer. + if (token.error) session.error = token.error; + 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 (imported async) INTO the config before +// constructing NextAuth. Config MUST be a static object — a config *function* +// makes NextAuth's `auth` async, so `auth((req) => …)` returns a Promise instead +// of a callable middleware ("authMiddleware is not a function"). Top-level await +// is fine on the Node runtime the middleware uses; a no-op for client_secret. +for (const p of config.providers ?? []) { + const tok = (p as { token?: { clientPrivateKey?: unknown } }).token; + if (tok && tok.clientPrivateKey instanceof Promise) { + tok.clientPrivateKey = await tok.clientPrivateKey; + } +} + +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..ecab65ae 100644 --- a/agentex-ui/components/providers/agentex-provider.tsx +++ b/agentex-ui/components/providers/agentex-provider.tsx @@ -20,6 +20,7 @@ import { 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 +38,12 @@ const AgentexContext = createContext(null); export function AgentexProvider({ children, sgpAppURL, + authEnabled, accountsEnabled, }: { children: ReactNode; sgpAppURL: string; + authEnabled: boolean; accountsEnabled: boolean; }) { const { sgpAccountID, updateParams } = useSafeSearchParams(); @@ -95,6 +98,7 @@ export function AgentexProvider({ value={{ agentexClient, sgpAppURL, + authEnabled, accountsEnabled, selectedAccountId: sgpAccountID, setSelectedAccountId, diff --git a/agentex-ui/components/session-guard.tsx b/agentex-ui/components/session-guard.tsx new file mode 100644 index 00000000..dc66a581 --- /dev/null +++ b/agentex-ui/components/session-guard.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useSession } from 'next-auth/react'; + +/** + * Forces re-authentication on a terminal refresh error (the refresh token expired or was + * revoked). Routes through the server-side auto-signin handler, which silently re-auths + * if the provider session is still valid, otherwise shows the login. Complements the + * middleware, which only redirects on navigation — this covers no-navigation sessions + * (e.g. an active chat making only API calls). + */ +export function SessionGuard() { + const { data: session } = useSession(); + const error = session?.error; + + useEffect(() => { + 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..c3520071 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,41 @@ 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 }) { + const handleLogout = () => { + window.location.href = '/api/auth/logout'; + }; + + 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..afd2665e --- /dev/null +++ b/agentex-ui/middleware.ts @@ -0,0 +1,39 @@ +import { + NextResponse, + type NextFetchEvent, + type NextMiddleware, + type NextRequest, +} from 'next/server'; + +import { auth, authEnabled } from '@/auth'; + +// Standard BFF pattern: validate the auth.js session (the wrapper reassembles +// chunked cookies, verifies + rotates tokens — middleware is a cookie-writable +// context) and route unauthenticated users through the server-side auto-signin +// handler so the sign-in → provider redirect happens server-side (avoids the +// RSC-seed refresh loop). +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", From 8b242e3b886b2ad7a3175f2220277f3241ce9fc9 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 15:21:29 -0400 Subject: [PATCH 2/6] fix(agentex-ui): resolve OIDC token + logout endpoints via discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile P1s. The refresh path and RP-initiated logout hardcoded Ory's `/oauth2/token` and `/oauth2/sessions/logout` paths, which 404 on non-Ory IdPs — silently forcing a re-auth loop (refresh) or a transparent re-login (logout). Resolve `token_endpoint` and `end_session_endpoint` from the issuer's `.well-known/openid-configuration` (cached per-process; failures not cached). No Ory-specific fallback: a missing token_endpoint fails the refresh cleanly (→ re-auth), and a missing end_session_endpoint means local-only logout rather than a guessed path. OneAuth (Ory Hydra) advertises both in discovery, and NextAuth already relies on the same document for sign-in. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/app/api/auth/logout/route.ts | 8 ++--- agentex-ui/auth.ts | 48 +++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/agentex-ui/app/api/auth/logout/route.ts b/agentex-ui/app/api/auth/logout/route.ts index cc85fc09..a76f85ab 100644 --- a/agentex-ui/app/api/auth/logout/route.ts +++ b/agentex-ui/app/api/auth/logout/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { getToken } from 'next-auth/jwt'; -import { signOut } from '@/auth'; +import { oidcEndSessionEndpoint, signOut } from '@/auth'; /** * RP-initiated (SSO) logout: clear the local NextAuth session AND end the provider's @@ -19,9 +19,9 @@ export async function GET(req: Request): Promise { await signOut({ redirect: false }); // clears the session cookie const origin = process.env.AUTH_URL ?? new URL(req.url).origin; - const issuer = process.env.OIDC_ISSUER_URL; - if (issuer && idToken) { - const url = new URL(`${issuer}/oauth2/sessions/logout`); + 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 NextResponse.redirect(url); diff --git a/agentex-ui/auth.ts b/agentex-ui/auth.ts index 300f875e..64bbfd82 100644 --- a/agentex-ui/auth.ts +++ b/agentex-ui/auth.ts @@ -8,6 +8,9 @@ import NextAuth, { type NextAuthConfig } from 'next-auth'; * registration is private_key_jwt-only) * - else OIDC_CLIENT_SECRET → client_secret_post (convenient for local dev) * + * Token + end-session endpoints are resolved from the issuer's OIDC discovery document, + * so the refresh and logout paths work for any compliant IdP — not just Ory's paths. + * * Route protection + auto sign-in live in middleware.ts (which redirects to the * server-side /api/auth/auto-signin handler) — a standard BFF pattern. Env is read * lazily so `next build` works without env present. @@ -56,6 +59,47 @@ function env() { }; } +const trimSlash = (u: string) => u.replace(/\/$/, ''); + +// ─── OIDC discovery ─── +// The refresh + logout paths need the token/end-session endpoints. Resolve them from +// the issuer's well-known document so any IdP works (not just Ory's fixed paths); +// cache per-process and fall back to the Ory conventions if discovery is unavailable. +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>; + }) + .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 = ''; @@ -137,8 +181,7 @@ function buildProvider(id: string) { } function buildConfig(): NextAuthConfig { - const { issuer, clientId, clientSecret, privateKeyJwk } = env(); - const tokenEndpoint = `${issuer}/oauth2/token`; + const { clientId, clientSecret, privateKeyJwk } = env(); const usePkJwt = !!privateKeyJwk && privateKeyJwk.trim().length > 0; return { @@ -161,6 +204,7 @@ function buildConfig(): NextAuthConfig { token.refreshToken ) { try { + const tokenEndpoint = await oidcTokenEndpoint(); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: token.refreshToken, From 9a3f852df8852a5b7fdcd3a0ccf309c80b3c0b89 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 17:01:10 -0400 Subject: [PATCH 3/6] fix(agentex-ui): make logout POST-only to close GET-logout CSRF A GET /api/auth/logout is reachable by a crafted cross-site navigation (SameSite=Lax sends the session cookie on top-level GET), letting an attacker end the user's IdP SSO session. Switch to POST (Lax cookies aren't sent on cross-site POST); the client POSTs and follows the returned RP-initiated logout URL. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/app/api/auth/logout/route.ts | 13 ++++++++----- .../components/task-sidebar/task-sidebar-footer.tsx | 12 ++++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/agentex-ui/app/api/auth/logout/route.ts b/agentex-ui/app/api/auth/logout/route.ts index a76f85ab..0cc98e41 100644 --- a/agentex-ui/app/api/auth/logout/route.ts +++ b/agentex-ui/app/api/auth/logout/route.ts @@ -1,5 +1,3 @@ -import { NextResponse } from 'next/server'; - import { getToken } from 'next-auth/jwt'; import { oidcEndSessionEndpoint, signOut } from '@/auth'; @@ -8,8 +6,13 @@ import { oidcEndSessionEndpoint, signOut } from '@/auth'; * RP-initiated (SSO) logout: clear the local NextAuth session AND end the provider's * SSO (OP) session, so middleware auto-signin doesn't silently log the user back in. * The id_token is read server-side (getToken) and never exposed on the session. + * + * POST-only, by design: logout is destructive (it can end the whole IdP SSO session), and + * a SameSite=Lax session cookie is NOT sent on cross-site POSTs — so a crafted cross-site + * link can't trigger it (GET-logout CSRF). The client POSTs here and then navigates to the + * returned RP-initiated logout `url`. */ -export async function GET(req: Request): Promise { +export async function POST(req: Request): Promise { const token = await getToken({ req: req as never, secret: process.env.AUTH_SECRET ?? '', @@ -24,7 +27,7 @@ export async function GET(req: Request): Promise { const url = new URL(endSession); url.searchParams.set('id_token_hint', idToken); url.searchParams.set('post_logout_redirect_uri', origin); - return NextResponse.redirect(url); + return Response.json({ url: url.toString() }); } - return NextResponse.redirect(origin); + return Response.json({ url: origin }); } diff --git a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx index c3520071..0e6d29ca 100644 --- a/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx +++ b/agentex-ui/components/task-sidebar/task-sidebar-footer.tsx @@ -61,8 +61,16 @@ export function TaskSidebarFooter({ // 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 }) { - const handleLogout = () => { - window.location.href = '/api/auth/logout'; + // 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) { From 5cd4b21936a229dbdda16403d6e43bf2520b8bab Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Wed, 8 Jul 2026 17:12:17 -0400 Subject: [PATCH 4/6] =?UTF-8?q?docs(agentex-ui):=20tighten=20auth-layer=20?= =?UTF-8?q?comments;=20rename=20legacy=20=E2=86=92=20default=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim verbose/redundant comments across auth.ts, middleware, session-guard, layout and the auth routes; drop provider-specific ("Ory") wording (endpoints come from discovery); rename the non-auth "legacy mode" to "default mode". No behavior change. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/app/api/auth/auto-signin/route.ts | 10 ++---- agentex-ui/app/api/auth/logout/route.ts | 12 +++---- agentex-ui/app/layout.tsx | 4 +-- agentex-ui/auth.ts | 38 ++++++++------------ agentex-ui/components/session-guard.tsx | 8 ++--- agentex-ui/middleware.ts | 8 ++--- 6 files changed, 30 insertions(+), 50 deletions(-) diff --git a/agentex-ui/app/api/auth/auto-signin/route.ts b/agentex-ui/app/api/auth/auto-signin/route.ts index 518227a3..c5a66df5 100644 --- a/agentex-ui/app/api/auth/auto-signin/route.ts +++ b/agentex-ui/app/api/auth/auto-signin/route.ts @@ -1,13 +1,9 @@ import { providerId, signIn } from '@/auth'; /** - * Server-side auto sign-in: middleware sends unauthenticated users here so the - * OIDC redirect to the provider is initiated server-side (NextAuth sets the - * PKCE/state cookies + performs the redirect). Single provider, so no picker page. - * - * Relies on `signIn` performing the redirect (throwing NEXT_REDIRECT) in a GET - * route handler; if it returns a URL instead, use - * `return Response.redirect(await signIn(providerId, { redirectTo, redirect: false }))`. + * 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 }); diff --git a/agentex-ui/app/api/auth/logout/route.ts b/agentex-ui/app/api/auth/logout/route.ts index 0cc98e41..e1ace421 100644 --- a/agentex-ui/app/api/auth/logout/route.ts +++ b/agentex-ui/app/api/auth/logout/route.ts @@ -3,14 +3,12 @@ import { getToken } from 'next-auth/jwt'; import { oidcEndSessionEndpoint, signOut } from '@/auth'; /** - * RP-initiated (SSO) logout: clear the local NextAuth session AND end the provider's - * SSO (OP) session, so middleware auto-signin doesn't silently log the user back in. - * The id_token is read server-side (getToken) and never exposed on the session. + * 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, by design: logout is destructive (it can end the whole IdP SSO session), and - * a SameSite=Lax session cookie is NOT sent on cross-site POSTs — so a crafted cross-site - * link can't trigger it (GET-logout CSRF). The client POSTs here and then navigates to the - * returned RP-initiated logout `url`. + * 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 getToken({ diff --git a/agentex-ui/app/layout.tsx b/agentex-ui/app/layout.tsx index 76ac6224..0942116a 100644 --- a/agentex-ui/app/layout.tsx +++ b/agentex-ui/app/layout.tsx @@ -28,8 +28,8 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - // Gate NextAuth's SessionProvider on auth: in direct (legacy) mode it never - // mounts, so the client makes no /api/auth/session calls. + // 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 = ( diff --git a/agentex-ui/auth.ts b/agentex-ui/auth.ts index 64bbfd82..a40df094 100644 --- a/agentex-ui/auth.ts +++ b/agentex-ui/auth.ts @@ -1,19 +1,14 @@ import NextAuth, { type NextAuthConfig } from 'next-auth'; /** - * Generic OIDC auth for agentex-ui — vanilla NextAuth v5, the IdP is fully env-driven. + * Generic OIDC auth for agentex-ui — vanilla NextAuth v5, IdP is env-driven. * - * Client authentication is env-selected: - * - OIDC_PRIVATE_KEY_JWK set → private_key_jwt (matches deployments whose client - * registration is private_key_jwt-only) - * - else OIDC_CLIENT_SECRET → client_secret_post (convenient for local dev) + * 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. * - * Token + end-session endpoints are resolved from the issuer's OIDC discovery document, - * so the refresh and logout paths work for any compliant IdP — not just Ory's paths. - * - * Route protection + auto sign-in live in middleware.ts (which redirects to the - * server-side /api/auth/auto-signin handler) — a standard BFF pattern. Env is read - * lazily so `next build` works without env present. + * Route protection + auto sign-in live in middleware.ts. Env is read lazily so + * `next build` works without it. */ const CLIENT_ASSERTION_TYPE = @@ -25,10 +20,9 @@ const CLIENT_ASSERTION_TYPE = const REFRESH_SKEW_S = 300; /** - * The provider id BOTH selects the OIDC provider AND enables auth: set - * AGENTEX_UI_AUTH_PROVIDER_ID to turn login on (unset = original direct behavior). - * Its value must equal the callback segment registered as the IdP redirect_uri - * (`/api/auth/callback/`) — e.g. `oneauth`. + * 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; @@ -61,10 +55,8 @@ function env() { const trimSlash = (u: string) => u.replace(/\/$/, ''); -// ─── OIDC discovery ─── -// The refresh + logout paths need the token/end-session endpoints. Resolve them from -// the issuer's well-known document so any IdP works (not just Ory's fixed paths); -// cache per-process and fall back to the Ory conventions if discovery is unavailable. +// 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> { @@ -284,11 +276,9 @@ if (authEnabled) { } } -// Resolve any private_key_jwt CryptoKey (imported async) INTO the config before -// constructing NextAuth. Config MUST be a static object — a config *function* -// makes NextAuth's `auth` async, so `auth((req) => …)` returns a Promise instead -// of a callable middleware ("authMiddleware is not a function"). Top-level await -// is fine on the Node runtime the middleware uses; a no-op for client_secret. +// 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 tok = (p as { token?: { clientPrivateKey?: unknown } }).token; if (tok && tok.clientPrivateKey instanceof Promise) { diff --git a/agentex-ui/components/session-guard.tsx b/agentex-ui/components/session-guard.tsx index dc66a581..3a23deaf 100644 --- a/agentex-ui/components/session-guard.tsx +++ b/agentex-ui/components/session-guard.tsx @@ -5,11 +5,9 @@ import { useEffect } from 'react'; import { useSession } from 'next-auth/react'; /** - * Forces re-authentication on a terminal refresh error (the refresh token expired or was - * revoked). Routes through the server-side auto-signin handler, which silently re-auths - * if the provider session is still valid, otherwise shows the login. Complements the - * middleware, which only redirects on navigation — this covers no-navigation sessions - * (e.g. an active chat making only API calls). + * Forces re-auth on a terminal refresh error (refresh token expired/revoked) via the + * server-side auto-signin handler. Complements the middleware (which only redirects on + * navigation) — this covers sessions making only API calls, e.g. an active chat. */ export function SessionGuard() { const { data: session } = useSession(); diff --git a/agentex-ui/middleware.ts b/agentex-ui/middleware.ts index afd2665e..0ffa136c 100644 --- a/agentex-ui/middleware.ts +++ b/agentex-ui/middleware.ts @@ -7,11 +7,9 @@ import { import { auth, authEnabled } from '@/auth'; -// Standard BFF pattern: validate the auth.js session (the wrapper reassembles -// chunked cookies, verifies + rotates tokens — middleware is a cookie-writable -// context) and route unauthenticated users through the server-side auto-signin -// handler so the sign-in → provider redirect happens server-side (avoids the -// RSC-seed refresh loop). +// 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 From 676365ad31b1ab887cfc2bff85495cfc40d06290 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Thu, 9 Jul 2026 11:57:41 -0400 Subject: [PATCH 5/6] fix(agentex-ui): harden OIDC auth (review follow-ups) - getToken passes secureCookie (from AUTH_URL) so the BFF finds the `__Secure-` session cookie over HTTPS; without it the Bearer was dropped (401s) and logout skipped end-session. - Refresh: only invalid_grant is terminal; transient failures keep the token and retry. - OIDC discovery: don't cache a 200 that's missing token_endpoint. - private_key_jwt: add `kid` to the login client_assertion (parity with refresh). - auto-signin: short-circuit when already authenticated; sanitize the redirect target. - /api/auth/*: 404 in default (no-auth) mode instead of the provider-less handler's 500. - SDK fetch: on a 401, refresh the session (deduped) and retry once. - Expose the non-secret `sub` claim on the session (access/id/refresh tokens stay server-side). Co-Authored-By: Claude Opus 4.8 --- agentex-ui/app/api/_lib/bff.ts | 14 ++-- .../app/api/auth/[...nextauth]/route.ts | 8 ++- agentex-ui/app/api/auth/auto-signin/route.ts | 13 +++- agentex-ui/app/api/auth/logout/route.ts | 11 +-- agentex-ui/auth.ts | 72 ++++++++++++++----- .../components/providers/agentex-provider.tsx | 36 +++++++--- 6 files changed, 108 insertions(+), 46 deletions(-) diff --git a/agentex-ui/app/api/_lib/bff.ts b/agentex-ui/app/api/_lib/bff.ts index 84862e27..e63e4984 100644 --- a/agentex-ui/app/api/_lib/bff.ts +++ b/agentex-ui/app/api/_lib/bff.ts @@ -1,6 +1,4 @@ -import { getToken } from 'next-auth/jwt'; - -import { authEnabled } from '@/auth'; +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 = @@ -41,12 +39,10 @@ export async function applyBffCredentials( if (authEnabled) { headers.delete('cookie'); - const token = await getToken({ - req: req as never, - secret: process.env.AUTH_SECRET ?? '', - }); - const accessToken = token?.accessToken as string | undefined; - if (accessToken) headers.set('authorization', `Bearer ${accessToken}`); + const token = await getSessionToken(req); + if (token?.accessToken) { + headers.set('authorization', `Bearer ${token.accessToken}`); + } return; } diff --git a/agentex-ui/app/api/auth/[...nextauth]/route.ts b/agentex-ui/app/api/auth/[...nextauth]/route.ts index 0a98352f..b07ac345 100644 --- a/agentex-ui/app/api/auth/[...nextauth]/route.ts +++ b/agentex-ui/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,7 @@ -import { handlers } from '@/auth'; +import { authEnabled, handlers } from '@/auth'; -export const { GET, POST } = handlers; +// 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 index c5a66df5..733bc298 100644 --- a/agentex-ui/app/api/auth/auto-signin/route.ts +++ b/agentex-ui/app/api/auth/auto-signin/route.ts @@ -1,4 +1,4 @@ -import { providerId, signIn } from '@/auth'; +import { auth, providerId, signIn } from '@/auth'; /** * Server-side auto sign-in: the middleware sends unauthenticated users here so NextAuth @@ -7,7 +7,16 @@ import { providerId, signIn } from '@/auth'; */ export async function GET(req: Request): Promise { if (!providerId) return new Response(null, { status: 404 }); - const redirectTo = new URL(req.url).searchParams.get('redirect_url') ?? '/'; + 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 index e1ace421..e1790488 100644 --- a/agentex-ui/app/api/auth/logout/route.ts +++ b/agentex-ui/app/api/auth/logout/route.ts @@ -1,6 +1,4 @@ -import { getToken } from 'next-auth/jwt'; - -import { oidcEndSessionEndpoint, signOut } from '@/auth'; +import { getSessionToken, oidcEndSessionEndpoint, signOut } from '@/auth'; /** * RP-initiated (SSO) logout: clear the local session AND end the provider's SSO session, @@ -11,11 +9,8 @@ import { oidcEndSessionEndpoint, signOut } from '@/auth'; * trigger logout (GET-logout CSRF). The client POSTs, then navigates to the returned `url`. */ export async function POST(req: Request): Promise { - const token = await getToken({ - req: req as never, - secret: process.env.AUTH_SECRET ?? '', - }); - const idToken = token?.idToken as string | undefined; + const token = await getSessionToken(req); + const idToken = token?.idToken; await signOut({ redirect: false }); // clears the session cookie diff --git a/agentex-ui/auth.ts b/agentex-ui/auth.ts index a40df094..4935f0fb 100644 --- a/agentex-ui/auth.ts +++ b/agentex-ui/auth.ts @@ -1,4 +1,5 @@ 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. @@ -30,8 +31,9 @@ export const authEnabled = !!providerId; // `| undefined` is explicit so assignments compile under exactOptionalPropertyTypes. declare module 'next-auth' { interface Session { - // BFF: the access token is NOT exposed on the session (stays in the JWT/cookie). + // 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' { @@ -55,6 +57,19 @@ function env() { 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; @@ -67,6 +82,13 @@ function discoverOidc(): Promise> { 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 {}; @@ -154,14 +176,17 @@ function buildProvider(id: string) { 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' }, - // Promise — resolved before @auth/core reads it (see export). + // { 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: importEs256Key( - JSON.parse(privateKeyJwk) - ) as unknown as CryptoKey, + clientPrivateKey: { + key: importEs256Key(jwk) as unknown as CryptoKey, + ...(jwk.kid ? { kid: jwk.kid } : {}), + }, }, }; } @@ -182,13 +207,20 @@ function buildConfig(): NextAuthConfig { session: { strategy: 'jwt' }, providers: providerId ? [buildProvider(providerId)] : [], callbacks: { - async jwt({ token, account }) { + 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 && @@ -221,9 +253,17 @@ function buildConfig(): NextAuthConfig { body, }); if (!res.ok) { - token.accessToken = undefined; - token.refreshToken = undefined; - return { ...token, error: 'RefreshAccessTokenError' }; + // Only invalid_grant (dead refresh token) is terminal; keep the token on + // transient failures so the next cycle retries. + const body = (await res.json().catch(() => ({}))) as { + error?: string; + }; + if (res.status === 400 && body.error === 'invalid_grant') { + token.accessToken = undefined; + token.refreshToken = undefined; + return { ...token, error: 'RefreshAccessTokenError' }; + } + return token; } const r = (await res.json()) as { access_token?: string; @@ -239,17 +279,14 @@ function buildConfig(): NextAuthConfig { if (r.refresh_token) token.refreshToken = r.refresh_token; if (r.id_token) token.idToken = r.id_token; } catch { - token.accessToken = undefined; - token.refreshToken = undefined; - return { ...token, error: 'RefreshAccessTokenError' }; + return token; // transient — keep the token, retry next cycle } } return token; }, session({ session, token }) { - // BFF: keep the access token server-side (JWT/cookie); never on session. - // The /api/agentex proxy reads it via getToken() and attaches the Bearer. if (token.error) session.error = token.error; + session.sub = token.sub; return session; }, }, @@ -280,9 +317,10 @@ if (authEnabled) { // 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 tok = (p as { token?: { clientPrivateKey?: unknown } }).token; - if (tok && tok.clientPrivateKey instanceof Promise) { - tok.clientPrivateKey = await tok.clientPrivateKey; + const pk = (p as { token?: { clientPrivateKey?: { key?: unknown } } }).token + ?.clientPrivateKey; + if (pk && pk.key instanceof Promise) { + pk.key = await pk.key; } } diff --git a/agentex-ui/components/providers/agentex-provider.tsx b/agentex-ui/components/providers/agentex-provider.tsx index ecab65ae..6b71d1a1 100644 --- a/agentex-ui/components/providers/agentex-provider.tsx +++ b/agentex-ui/components/providers/agentex-provider.tsx @@ -17,6 +17,18 @@ 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; @@ -79,19 +91,27 @@ 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 ( Date: Thu, 9 Jul 2026 13:03:06 -0400 Subject: [PATCH 6/6] fix(agentex-ui): escalate refresh failures by status class, not just invalid_grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any 4xx (except 429) means the grant/client/request is bad and won't recover by retrying → terminal (force re-auth); 429/5xx and network errors stay transient (keep the token, retry next cycle). Broadens the terminal set beyond invalid_grant so a persistent invalid_client (e.g. rotated secret) surfaces a real error instead of silently forwarding an expired token, while still not logging users out on a transient IdP blip. Drops the now-redundant response-body parse. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/auth.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/agentex-ui/auth.ts b/agentex-ui/auth.ts index 4935f0fb..13c1eec8 100644 --- a/agentex-ui/auth.ts +++ b/agentex-ui/auth.ts @@ -221,6 +221,7 @@ function buildConfig(): NextAuthConfig { if (profile.email) token.email = profile.email; if (profile.picture) token.picture = profile.picture; } + const expiresAt = token.expiresAt; if ( expiresAt && @@ -234,6 +235,7 @@ function buildConfig(): NextAuthConfig { refresh_token: token.refreshToken, client_id: clientId, }); + if (usePkJwt) { body.set('client_assertion_type', CLIENT_ASSERTION_TYPE); body.set( @@ -252,13 +254,11 @@ function buildConfig(): NextAuthConfig { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); + if (!res.ok) { - // Only invalid_grant (dead refresh token) is terminal; keep the token on - // transient failures so the next cycle retries. - const body = (await res.json().catch(() => ({}))) as { - error?: string; - }; - if (res.status === 400 && body.error === 'invalid_grant') { + // 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' };