Commit 824286c
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
- app
- api
- _lib
- auth
- [...nextauth]
- auto-signin
- logout
- login
- components
- providers
- task-sidebar
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
105 | 116 | | |
106 | 117 | | |
107 | 118 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
1 | 3 | | |
2 | 4 | | |
3 | 5 | | |
| |||
20 | 22 | | |
21 | 23 | | |
22 | 24 | | |
23 | | - | |
24 | | - | |
25 | | - | |
26 | | - | |
27 | | - | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
39 | 50 | | |
40 | 51 | | |
41 | 52 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
| 4 | + | |
3 | 5 | | |
| 6 | + | |
4 | 7 | | |
5 | 8 | | |
6 | 9 | | |
| |||
25 | 28 | | |
26 | 29 | | |
27 | 30 | | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
28 | 47 | | |
29 | 48 | | |
30 | 49 | | |
31 | 50 | | |
32 | 51 | | |
33 | | - | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
43 | 62 | | |
44 | 63 | | |
45 | 64 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
15 | 16 | | |
16 | | - | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
17 | 22 | | |
18 | 23 | | |
19 | 24 | | |
| |||
0 commit comments