Skip to content

feat(agentex-ui): OIDC login with server-side access token#351

Open
erichwoo-scale wants to merge 4 commits into
feat/agentex-ui-account-pickerfrom
feat/agentex-ui-oidc-auth
Open

feat(agentex-ui): OIDC login with server-side access token#351
erichwoo-scale wants to merge 4 commits into
feat/agentex-ui-account-pickerfrom
feat/agentex-ui-oidc-auth

Conversation

@erichwoo-scale

@erichwoo-scale erichwoo-scale commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Second of a 2-PR stack. Stacked on #350base 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.

Demo

agentex-ui-oidc-auth.mov

Stack

  1. feat(agentex-ui): account picker via same-origin BFF proxy #350 — account picker + BFF proxy
  2. This PR — OIDC login → stacked on feat(agentex-ui): account picker via same-origin BFF proxy #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

  • npm run typecheck / npm run lint — clean
  • Runtime login smoke test (sign-in → agents load via Bearer → logout ends the IdP session)
  • Chart wiring — done in scaleapi/sgp#3961

🤖 Generated with Claude Code

@erichwoo-scale erichwoo-scale requested a review from a team as a code owner July 8, 2026 18:36
@socket-security

socket-security Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​next-auth@​5.0.0-beta.31991007886100

View full report

Comment thread agentex-ui/auth.ts Outdated
Comment thread agentex-ui/app/api/auth/logout/route.ts Outdated
@erichwoo-scale erichwoo-scale force-pushed the feat/agentex-ui-oidc-auth branch from bb896c7 to c53787d Compare July 8, 2026 19:21
@erichwoo-scale

Copy link
Copy Markdown
Contributor Author

Addressed the Greptile P1s in c53787d:

  • Hardcoded token endpoint (auth.ts) and hardcoded end_session_endpoint (logout/route.ts): both are now resolved from the issuer's .well-known/openid-configuration (cached per-process), with the Ory paths kept only as a last-resort fallback. This fixes the silent refresh→re-auth loop and the transparent re-login on non-Ory IdPs, and makes the "generic OIDC" claim actually hold.

Rebased onto the updated base branch so the stack stays clean; the footer now renders Give Feedback → Account Picker → Log out (account picker placement lands in #350).

@erichwoo-scale erichwoo-scale force-pushed the feat/agentex-ui-oidc-auth branch 2 times, most recently from 6e749ef to db8bec0 Compare July 8, 2026 19:31
Comment thread agentex-ui/app/api/auth/logout/route.ts Outdated
@erichwoo-scale erichwoo-scale force-pushed the feat/agentex-ui-oidc-auth branch from db8bec0 to 00d2195 Compare July 8, 2026 19:40
@erichwoo-scale erichwoo-scale force-pushed the feat/agentex-ui-account-picker branch from 8eb5b47 to 5f243a0 Compare July 8, 2026 20:00
@erichwoo-scale erichwoo-scale force-pushed the feat/agentex-ui-oidc-auth branch 4 times, most recently from 5d58576 to 054fad9 Compare July 8, 2026 21:31
erichwoo-scale and others added 4 commits July 8, 2026 17:41
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…t mode

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 <noreply@anthropic.com>
@declan-scale

Copy link
Copy Markdown
Collaborator

I went through this myself and it looks good. I also had Claude do a review pass focused on regressions and open-source (auth-disabled) safety, and it surfaced 6 items worth a look.

Default path is clean — verified with a real next build + next start (no AGENTEX_UI_AUTH_PROVIDER_ID): pages serve 200, BFF cookie-forwarding is preserved, no SessionProvider//api/auth/session calls, and Node.js middleware needs no experimental flag on next@15.5.18. One cosmetic note: /api/auth/session now returns 500 instead of 404 in default mode (unreachable in normal use; guard on authEnabled if you want it to stay 404).

A few things to address on the auth-enabled path, roughly by severity:

  1. getToken() reads the wrong cookie name over HTTPS (blocking). app/api/_lib/bff.ts and app/api/auth/logout/route.ts call getToken({ req, secret }) with no secureCookie/cookieName. Over TLS, NextAuth writes __Secure-authjs.session-token, but getToken defaults to the non-secure name and returns null → no Bearer attached (all /api/agentex/* → 401) and logout skips the RP-initiated end-session (IdP SSO survives, middleware signs the user back in). Works in local HTTP dev, which is likely why the smoke test passed. Suggest a shared helper that derives secureCookie from the forwarded proto.

  2. Transient refresh failures permanently kill the session. auth.ts (jwt callback): the !res.ok and catch branches wipe refreshToken and set terminal RefreshAccessTokenError for any failure. A single 5xx/network/discovery blip on a 240s refetch logs out every active user with no retry. Only 400 invalid_grant should be terminal; return the token unchanged otherwise.

  3. Discovery caches an incomplete 200 doc forever. discoverOidc() only invalidates on fetch failure — a 200 missing token_endpoint is cached for the process lifetime. Combined with Bump cross-spawn from 7.0.3 to 7.0.6 in /agentex-web #2, a brief malformed .well-known mass-logs-out sessions until restart. Invalidate on semantic validation too.

  4. BFF can forward a stale/expired token. Refresh only runs in cookie-writable contexts; the middleware matcher excludes api/, so the 240s SessionProvider poll is the only refresher. Short access-token lifetimes or a backgrounded/throttled tab can let an expired Bearer go upstream. Consider short-circuiting in the BFF when expiresAt is past, or re-auth on upstream 401.

  5. private_key_jwt omits kid on the login exchange but sets it on refresh (buildProvider vs signClientAssertion). If the IdP requires kid to select the key, initial login fails invalid_client while refresh works.

  6. /login → auto-signin does redundant OIDC round-trips with no already-authenticated check; benign today via SSO short-circuit, but an IdP with prompt=login would make it a visible re-prompt.

@declan-scale declan-scale left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgot to hit approve, just double check that first claude point and I think we should be good

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants