Skip to content

Commit 824286c

Browse files
feat(agentex-ui): OIDC login with server-side access token (#351)
## Summary Second of a **2-PR stack**. Stacked on #350 — **base is `feat/agentex-ui-account-picker`**, so this diff is auth-only (GitHub auto-retargets to `main` once #350 merges). The chart + deploy wiring lives in scaleapi/sgp#3961. Adds opt-in OIDC login on top of the BFF, keeping the access token out of the browser entirely. - NextAuth (generic OIDC provider) selected **and** enabled by a single env var, `AGENTEX_UI_AUTH_PROVIDER_ID`; disabled by default so non-auth deployments are unaffected (no `SessionProvider`, 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`). - Token/end-session endpoints are resolved from the issuer's **OIDC discovery** document (cached; failures retried), so refresh and logout work for any compliant IdP — no hardcoded provider paths. - Middleware auto-redirects unauthenticated requests to sign-in; a client guard re-auths on refresh failure; **POST-only** RP-initiated logout ends the IdP session (a GET would be cross-site-triggerable — logout CSRF). - Supports `client_secret_post` (dev) and `private_key_jwt` (prod). ## Why token-hidden (BFF) over exposing it on the session Exposing the access token to client JS is contrary to the OAuth 2.0 for Browser-Based Apps BCP and would flag in security review. Keeping it server-side (the BFF attaches the Bearer) is the recommended pattern; the client only ever sees the same-origin proxy. ## Stack 1. #350 — account picker + BFF proxy 2. **This PR** — OIDC login → stacked on #350 3. scaleapi/sgp#3961 — Helm chart wires the OneAuth OIDC client + env (`AGENTEX_UI_AUTH_PROVIDER_ID`, `OIDC_ISSUER_URL`, the client Secret via `envFrom`) ## Test plan - [x] `npm run typecheck` / `npm run lint` — clean - [x] Runtime login smoke test (sign-in → agents load via Bearer → logout ends the IdP session) - [x] Chart wiring — done in scaleapi/sgp#3961 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR adds opt-in server-side OIDC authentication to agentex-ui via NextAuth v5. When `AGENTEX_UI_AUTH_PROVIDER_ID` is set, the middleware gates all page routes, the BFF attaches the access token as a `Bearer` header server-side (keeping it out of client JS), and a `LogoutButton` drives POST-only RP-initiated logout. - **OIDC discovery-based endpoint resolution** (`discoverOidc`) replaces the previously hardcoded Ory paths for both `token_endpoint` (refresh) and `end_session_endpoint` (logout), directly addressing the concerns raised in the last review cycle. - **Token refresh error handling** treats all 4xx (except 429) as terminal `RefreshAccessTokenError`, clearing the stale token and triggering `SessionGuard` to force re-auth — the prior `invalid_grant`-only terminal set has been broadened. - **Logout CSRF** is prevented by making `/api/auth/logout` POST-only, with `SameSite=Lax` cookies not forwarded on cross-site POSTs. <details><summary><h3>Confidence Score: 5/5</h3></summary> Safe to merge — all three blocking concerns from the previous review cycle are addressed; no new correctness issues were found. The auth flow is well-layered: discovery-backed endpoint resolution, POST-only logout with RP-initiated IdP session termination, and a broadened 4xx terminal set covering invalid_client and other non-retryable errors. The two remaining notes (indefinite discovery cache TTL and silent local-only logout when id_token is absent) are edge cases that do not affect correctness for a standard OIDC-compliant IdP. No files require special attention. auth.ts is the most complex file but is well-commented and structurally sound. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex-ui/auth.ts | New 327-line NextAuth v5 OIDC config with PKCE, private_key_jwt, discovery-based endpoint resolution, and JWT refresh. Discovery caching and startup validation are well-structured; P2: success path caches the discovery doc indefinitely with no TTL. | | agentex-ui/app/api/auth/logout/route.ts | POST-only RP-initiated logout using OIDC discovery for the end_session_endpoint. Properly addresses the CSRF and hardcoded-endpoint issues from the previous review cycle. | | agentex-ui/middleware.ts | Auth middleware that gates all page routes (not /api/) behind NextAuth; gated behind authEnabled so non-auth deployments are unaffected. | | agentex-ui/app/api/auth/auto-signin/route.ts | Server-side OIDC redirect initiation with open-redirect guard on redirect_url; correctly skips redundant round-trip for already-authenticated sessions. | | agentex-ui/app/api/_lib/bff.ts | BFF credential attachment updated to forward Bearer token in auth mode vs. cookies in default mode; correctly drops cookies when auth is enabled. | | agentex-ui/components/providers/agentex-provider.tsx | Adds 401 retry with deduplicated session refresh; the module-level promise dedup correctly handles concurrent 401s from a refocused tab. | | agentex-ui/components/session-guard.tsx | Client-side re-auth trigger on RefreshAccessTokenError; complements the middleware redirect for API-only sessions like an active chat. | | agentex-ui/components/task-sidebar/task-sidebar-footer.tsx | Adds LogoutButton gated by authEnabled; uses POST for logout to prevent CSRF; follows the server-provided RP-initiated logout URL correctly. | | agentex-ui/app/layout.tsx | Conditionally mounts SessionProvider with refetchInterval=240 when auth is enabled; re-derives authEnabled from env instead of importing from @/auth (addressed in prior review). | | agentex-ui/app/login/page.tsx | Simple redirect shim forwarding /login?redirect_url=... to auto-signin; open-redirect validation happens in auto-signin. | </details> <details><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Browser participant Middleware participant AutoSignin as /api/auth/auto-signin participant NextAuth as /api/auth/[...nextauth] participant IdP as OIDC IdP participant BFF as /api/agentex (BFF) participant Upstream as Upstream API Browser->>Middleware: GET /some-page (no session) Middleware->>AutoSignin: "redirect to auto-signin?redirect_url=/some-page" AutoSignin->>NextAuth: signIn(providerId, redirectTo) NextAuth->>Browser: 302 to IdP /authorize (PKCE + state) Browser->>IdP: follow OIDC auth redirect IdP->>NextAuth: callback to /api/auth/callback/id NextAuth->>NextAuth: jwt callback stores access_token (never on session) NextAuth->>Browser: set encrypted session cookie, redirect to /some-page Note over Browser,Middleware: Authenticated flow Browser->>BFF: fetch /api/agentex/... (session cookie) BFF->>BFF: getSessionToken(req) extracts accessToken BFF->>Upstream: GET ... Authorization: Bearer accessToken Upstream->>BFF: 200 OK BFF->>Browser: 200 OK Note over Browser,IdP: Logout flow Browser->>Browser: POST /api/auth/logout Browser->>NextAuth: signOut clears session cookie Browser->>IdP: "GET end_session_endpoint?id_token_hint=..." IdP->>Browser: redirect to post_logout_redirect_uri Note over Browser,NextAuth: Token refresh every 240s via SessionProvider Browser->>NextAuth: GET /api/auth/session NextAuth->>IdP: POST token_endpoint (refresh_token grant) IdP->>NextAuth: new access_token + refresh_token NextAuth->>Browser: rotated session cookie ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Browser participant Middleware participant AutoSignin as /api/auth/auto-signin participant NextAuth as /api/auth/[...nextauth] participant IdP as OIDC IdP participant BFF as /api/agentex (BFF) participant Upstream as Upstream API Browser->>Middleware: GET /some-page (no session) Middleware->>AutoSignin: "redirect to auto-signin?redirect_url=/some-page" AutoSignin->>NextAuth: signIn(providerId, redirectTo) NextAuth->>Browser: 302 to IdP /authorize (PKCE + state) Browser->>IdP: follow OIDC auth redirect IdP->>NextAuth: callback to /api/auth/callback/id NextAuth->>NextAuth: jwt callback stores access_token (never on session) NextAuth->>Browser: set encrypted session cookie, redirect to /some-page Note over Browser,Middleware: Authenticated flow Browser->>BFF: fetch /api/agentex/... (session cookie) BFF->>BFF: getSessionToken(req) extracts accessToken BFF->>Upstream: GET ... Authorization: Bearer accessToken Upstream->>BFF: 200 OK BFF->>Browser: 200 OK Note over Browser,IdP: Logout flow Browser->>Browser: POST /api/auth/logout Browser->>NextAuth: signOut clears session cookie Browser->>IdP: "GET end_session_endpoint?id_token_hint=..." IdP->>Browser: redirect to post_logout_redirect_uri Note over Browser,NextAuth: Token refresh every 240s via SessionProvider Browser->>NextAuth: GET /api/auth/session NextAuth->>IdP: POST token_endpoint (refresh_token grant) IdP->>NextAuth: new access_token + refresh_token NextAuth->>Browser: rotated session cookie ``` </a> </details> <sub>Reviews (10): Last reviewed commit: ["fix(agentex-ui): escalate refresh failur..."](56998b0) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=42798531)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0f07dc7 commit 824286c

16 files changed

Lines changed: 717 additions & 25 deletions

File tree

agentex-ui/DOCKER.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,17 @@ docker run --rm -p 3000:3000 \
102102

103103
# Or via an env file
104104
docker run --rm -p 3000:3000 --env-file .env.local agentex-ui:latest
105+
106+
# With OIDC login enabled: AGENTEX_UI_AUTH_PROVIDER_ID both selects the provider AND turns login on.
107+
docker run --rm -p 3000:3000 \
108+
-e AGENTEX_API_URL=https://agentex.example.com \
109+
-e AGENTEX_UI_AUTH_PROVIDER_ID=<your_provider> \
110+
-e OIDC_ISSUER_URL=https://auth.example.com \
111+
-e OIDC_CLIENT_ID=<your_client_id> \
112+
-e OIDC_PRIVATE_KEY_JWK='{"kty":"EC",...}' \
113+
-e AUTH_SECRET=change-me \
114+
-e AUTH_URL=https://chat.example.com \
115+
agentex-ui:latest
105116
```
106117

107118
## Registry Usage

agentex-ui/app/api/_lib/bff.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { authEnabled, getSessionToken } from '@/auth';
2+
13
/** Server-only platform API base for the BFF routes. Prefers SGP_API_URL, else the app URL. */
24
export const SGP_BASE_URL =
35
process.env.SGP_API_URL ??
@@ -20,11 +22,10 @@ function stripCookie(header: string | null, name: string): string | null {
2022

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

3738
headers.delete('authorization');
3839

40+
if (authEnabled) {
41+
headers.delete('cookie');
42+
const token = await getSessionToken(req);
43+
if (token?.accessToken) {
44+
headers.set('authorization', `Bearer ${token.accessToken}`);
45+
}
46+
return;
47+
}
48+
49+
// Default mode: forward cookies (minus `_jwt`) for the upstream's own cookie auth.
3950
const cookie = stripCookie(req.headers.get('cookie'), '_jwt');
4051
if (cookie) headers.set('cookie', cookie);
4152
else headers.delete('cookie');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { authEnabled, handlers } from '@/auth';
2+
3+
// No NextAuth routes in default mode — 404 rather than the provider-less handler's 500.
4+
const notFound = () => new Response(null, { status: 404 });
5+
6+
export const GET = authEnabled ? handlers.GET : notFound;
7+
export const POST = authEnabled ? handlers.POST : notFound;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { auth, providerId, signIn } from '@/auth';
2+
3+
/**
4+
* Server-side auto sign-in: the middleware sends unauthenticated users here so NextAuth
5+
* initiates the OIDC redirect (setting PKCE/state cookies) server-side. Single provider,
6+
* so no picker.
7+
*/
8+
export async function GET(req: Request): Promise<Response> {
9+
if (!providerId) return new Response(null, { status: 404 });
10+
const raw = new URL(req.url).searchParams.get('redirect_url') ?? '/';
11+
// Same-origin relative path only (open-redirect guard).
12+
const redirectTo = raw.startsWith('/') && !raw.startsWith('//') ? raw : '/';
13+
14+
// Already authenticated → skip the redundant OIDC round-trip.
15+
const session = await auth();
16+
if (session && !session.error) {
17+
return Response.redirect(new URL(redirectTo, new URL(req.url).origin));
18+
}
19+
20+
await signIn(providerId, { redirectTo });
21+
// Unreachable when signIn redirects; a 500 here signals it didn't.
22+
return new Response(null, { status: 500 });
23+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getSessionToken, oidcEndSessionEndpoint, signOut } from '@/auth';
2+
3+
/**
4+
* RP-initiated (SSO) logout: clear the local session AND end the provider's SSO session,
5+
* so middleware auto-signin doesn't silently log the user back in. The id_token is read
6+
* server-side and never exposed on the session.
7+
*
8+
* POST-only: a SameSite=Lax cookie isn't sent on cross-site POSTs, so a crafted link can't
9+
* trigger logout (GET-logout CSRF). The client POSTs, then navigates to the returned `url`.
10+
*/
11+
export async function POST(req: Request): Promise<Response> {
12+
const token = await getSessionToken(req);
13+
const idToken = token?.idToken;
14+
15+
await signOut({ redirect: false }); // clears the session cookie
16+
17+
const origin = process.env.AUTH_URL ?? new URL(req.url).origin;
18+
const endSession = await oidcEndSessionEndpoint();
19+
if (endSession && idToken) {
20+
const url = new URL(endSession);
21+
url.searchParams.set('id_token_hint', idToken);
22+
url.searchParams.set('post_logout_redirect_uri', origin);
23+
return Response.json({ url: url.toString() });
24+
}
25+
return Response.json({ url: origin });
26+
}

agentex-ui/app/layout.tsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Geist, Geist_Mono } from 'next/font/google';
22

3+
import { SessionProvider } from 'next-auth/react';
4+
35
import { QueryProvider, ThemeProvider } from '@/components/providers';
6+
import { SessionGuard } from '@/components/session-guard';
47

58
import type { Metadata } from 'next';
69
import '@/app/globals.css';
@@ -25,21 +28,37 @@ export default function RootLayout({
2528
}: Readonly<{
2629
children: React.ReactNode;
2730
}>) {
31+
// Gate SessionProvider on auth: in default mode it never mounts, so the client makes
32+
// no /api/auth/session calls.
33+
const authEnabled = !!process.env.AGENTEX_UI_AUTH_PROVIDER_ID;
34+
const tree = (
35+
<QueryProvider>
36+
<ThemeProvider
37+
attribute="class"
38+
defaultTheme="light"
39+
enableSystem={false}
40+
disableTransitionOnChange
41+
>
42+
{children}
43+
</ThemeProvider>
44+
</QueryProvider>
45+
);
46+
2847
return (
2948
<html lang="en" suppressHydrationWarning>
3049
<body
3150
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
3251
>
33-
<QueryProvider>
34-
<ThemeProvider
35-
attribute="class"
36-
defaultTheme="light"
37-
enableSystem={false}
38-
disableTransitionOnChange
39-
>
40-
{children}
41-
</ThemeProvider>
42-
</QueryProvider>
52+
{authEnabled ? (
53+
// refetchInterval drives the jwt-callback refresh below the token lifetime so
54+
// the proxy always reads a fresh token (it can't refresh on its own).
55+
<SessionProvider refetchInterval={240}>
56+
<SessionGuard />
57+
{tree}
58+
</SessionProvider>
59+
) : (
60+
tree
61+
)}
4362
</body>
4463
</html>
4564
);

agentex-ui/app/login/page.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { redirect } from 'next/navigation';
2+
3+
/**
4+
* Catches direct/stale hits on /login (e.g. logout or an SDK sending the browser
5+
* to `/login?redirect_url=…`) and forwards to the server-side auto-signin handler
6+
* so sign-in returns the user to their destination.
7+
*/
8+
export default async function LoginPage({
9+
searchParams,
10+
}: {
11+
searchParams: Promise<{ redirect_url?: string }>;
12+
}) {
13+
const { redirect_url } = await searchParams;
14+
redirect(
15+
`/api/auth/auto-signin?redirect_url=${encodeURIComponent(redirect_url ?? '/')}`
16+
);
17+
}

agentex-ui/app/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ export default async function RootPage() {
77
await connection();
88

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

1516
return (
16-
<AgentexProvider accountsEnabled={accountsEnabled} sgpAppURL={sgpAppURL}>
17+
<AgentexProvider
18+
authEnabled={authEnabled}
19+
accountsEnabled={accountsEnabled}
20+
sgpAppURL={sgpAppURL}
21+
>
1722
<AgentexUIRoot />
1823
</AgentexProvider>
1924
);

0 commit comments

Comments
 (0)