|
| 1 | +# EP-2045: SSO session-expiry re-authentication redirect |
| 2 | + |
| 3 | +* Issue: [#2045](https://github.com/kagent-dev/kagent/issues/2045) |
| 4 | + |
| 5 | +## Background |
| 6 | + |
| 7 | +kagent is frequently deployed behind [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) |
| 8 | +acting as an OIDC relying party. oauth2-proxy authenticates the user, maintains |
| 9 | +its own session cookie, and forwards the user's `id_token` to the kagent UI as a |
| 10 | +`Authorization: Bearer <jwt>` header. The UI decodes the JWT to derive the current |
| 11 | +user (`getCurrentUser`). |
| 12 | + |
| 13 | +Two distinct failure modes were conflated in the previous implementation, which |
| 14 | +decoded the token and returned `CurrentUser | null`: |
| 15 | + |
| 16 | +1. **No proxy in front of the UI** — there is no `Authorization` header at all. |
| 17 | + This is a valid "unsecured" deployment (local dev, no SSO). |
| 18 | +2. **Proxy session valid but forwarded `id_token` expired** — oauth2-proxy still |
| 19 | + holds a valid session cookie, but the `id_token` it forwards has expired (its |
| 20 | + lifetime is typically shorter than the cookie session). The JWT decodes to an |
| 21 | + expired token. |
| 22 | + |
| 23 | +Both previously collapsed to `null`, so the UI silently rendered a logged-out |
| 24 | +experience. In case (2) the correct behavior is to re-run the OIDC flow against |
| 25 | +oauth2-proxy (`/oauth2/start`) so a fresh `id_token` is minted, transparently |
| 26 | +restoring the session. In case (1) the UI must **not** redirect — there is no |
| 27 | +`/oauth2` endpoint, so a redirect would loop forever. |
| 28 | + |
| 29 | +## Motivation |
| 30 | + |
| 31 | +Users behind SSO are unexpectedly "logged out" mid-session when the forwarded |
| 32 | +`id_token` expires, even though their oauth2-proxy session is still valid. They |
| 33 | +must manually reload to recover. This is confusing and looks like a kagent bug. |
| 34 | + |
| 35 | +### Goals |
| 36 | + |
| 37 | +- Distinguish three auth states in the UI: `authenticated`, `expired`, `unsecured`. |
| 38 | +- On `expired`, transparently re-run the OIDC flow (redirect to the oauth2-proxy |
| 39 | + re-auth endpoint) so a fresh token is obtained without user intervention. |
| 40 | +- Never redirect in `unsecured` mode (no proxy), to avoid an infinite loop. |
| 41 | +- Guard against redirect loops if re-auth keeps returning a stale token. |
| 42 | + |
| 43 | +### Non-Goals |
| 44 | + |
| 45 | +- Changing the server-side token validation or the controller/HTTP server auth. |
| 46 | +- Implementing a kagent-native OIDC client (oauth2-proxy remains the RP). |
| 47 | +- Refresh-token handling inside the UI (delegated to oauth2-proxy). |
| 48 | + |
| 49 | +## Implementation Details |
| 50 | + |
| 51 | +### Auth status model (`ui/src/app/actions/auth.ts`) |
| 52 | + |
| 53 | +`getCurrentUser()` now returns an `AuthResult` instead of `CurrentUser | null`: |
| 54 | + |
| 55 | +```ts |
| 56 | +export type AuthStatus = "authenticated" | "expired" | "unsecured"; |
| 57 | + |
| 58 | +export interface AuthResult { |
| 59 | + status: AuthStatus; |
| 60 | + user: CurrentUser | null; |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +- No `Authorization: Bearer` header → `{ status: "unsecured", user: null }`. |
| 65 | +- Header present but token missing/expired (`isTokenExpired`) → `{ status: "expired", user: null }`. |
| 66 | +- Valid token → `{ status: "authenticated", user: claims }`. |
| 67 | + |
| 68 | +### Re-auth redirect (`ui/src/contexts/AuthContext.tsx`) |
| 69 | + |
| 70 | +`AuthProvider` exposes `status` alongside `user`. When `status === "expired"` |
| 71 | +(and only then) it redirects the browser to the oauth2-proxy re-auth endpoint, |
| 72 | +preserving the current location for return: |
| 73 | + |
| 74 | +```ts |
| 75 | +const SSO_REAUTH_PATH = process.env.NEXT_PUBLIC_SSO_REAUTH_PATH || "/oauth2/start"; |
| 76 | +const REAUTH_GUARD_KEY = "kagent_reauth_attempt"; |
| 77 | +const REAUTH_GUARD_WINDOW_MS = 10_000; |
| 78 | +// ...redirect to `${SSO_REAUTH_PATH}?rd=${encodeURIComponent(path + search)}` |
| 79 | +``` |
| 80 | + |
| 81 | +A `sessionStorage` guard (`REAUTH_GUARD_WINDOW_MS`) prevents a redirect loop: if a |
| 82 | +re-auth attempt happened within the window and the token is still expired, the UI |
| 83 | +surfaces an error instead of redirecting again. The guard is cleared on a |
| 84 | +successful `authenticated` result. |
| 85 | + |
| 86 | +### UI surface |
| 87 | + |
| 88 | +- `ui/src/components/UserMenu.tsx` — reflects the new status (e.g. distinguishes a |
| 89 | + logged-out/unsecured menu from an authenticated one). |
| 90 | +- `ui/src/app/login/page.tsx` — the branded login page (a server component) reads |
| 91 | + the server-side `SSO_REDIRECT_PATH`. `AuthContext` (a client component) reads the |
| 92 | + client-exposed `NEXT_PUBLIC_SSO_REDIRECT_PATH`; both default to `/oauth2/start` |
| 93 | + and the Helm chart injects both from `ui.auth.ssoRedirectPath`. |
| 94 | +- `docs/OIDC_PROXY_AUTH_ARCHITECTURE.md` — documents the three states and the |
| 95 | + re-auth flow. |
| 96 | + |
| 97 | +### Configuration |
| 98 | + |
| 99 | +| Env var | Default | Purpose | |
| 100 | +|---------|---------|---------| |
| 101 | +| `NEXT_PUBLIC_SSO_REAUTH_PATH` | `/oauth2/start` | oauth2-proxy endpoint that restarts the OIDC flow. | |
| 102 | + |
| 103 | +## Test Plan |
| 104 | + |
| 105 | +- **Unit (UI):** `getCurrentUser` returns the correct `AuthStatus` for: no header, |
| 106 | + expired token, valid token. `AuthProvider` redirects only on `expired`, never on |
| 107 | + `unsecured`, and respects the loop guard. |
| 108 | +- **Manual / e2e:** Deploy behind oauth2-proxy with a short `id_token` lifetime; |
| 109 | + confirm that on token expiry the UI redirects to `/oauth2/start?rd=...` and |
| 110 | + returns to the same page authenticated, without a logout flash. Confirm that a |
| 111 | + no-proxy deployment never redirects. |
| 112 | + |
| 113 | +## Alternatives |
| 114 | + |
| 115 | +- **Silent background refresh via fetch to `/oauth2/auth`** — more complex, and |
| 116 | + oauth2-proxy already mints a fresh token on a full `/oauth2/start` round-trip. |
| 117 | +- **Server-side redirect (middleware)** — harder to distinguish unsecured vs |
| 118 | + expired without the decoded claims available client-side, and risks loops. |
| 119 | + |
| 120 | +## Open Questions |
| 121 | + |
| 122 | +- Should the re-auth guard window be configurable per deployment? |
| 123 | +- Should `expired` optionally render a non-blocking toast ("re-authenticating…") |
| 124 | + before the redirect for slow networks? |
0 commit comments