Skip to content

Commit 8eabd19

Browse files
committed
feat(ui): SSO expired-token session redirect (#2045)
Distinguish authenticated/expired/unsecured auth states in the UI. When behind oauth2-proxy and the forwarded id_token has expired (but the proxy session is still valid), re-run the OIDC flow via /oauth2/start instead of silently rendering a logged-out UI. Never redirect in unsecured mode, and guard against redirect loops via a sessionStorage window. Review hardening: - jwt: treat a missing `exp` claim as expired rather than valid-forever, so a non-compliant id_token forces re-auth instead of being trusted. - AuthContext: widen the re-auth loop guard 10s -> 60s so a slow IdP round-trip isn't misread as a failed redirect loop. Includes jest coverage (getCurrentUser auth states, AuthProvider redirect/guard, jwt decode + isTokenExpired), design/EP-2045-sso-session-expiry-redirect.md, and NEXT_PUBLIC_SSO_REDIRECT_PATH wiring in the Helm chart. Signed-off-by: Dmytro Rashko <dmitriy.rashko@amdocs.com>
1 parent a766d22 commit 8eabd19

10 files changed

Lines changed: 431 additions & 15 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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?

docs/OIDC_PROXY_AUTH_ARCHITECTURE.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,19 @@ flowchart TD
129129
B -->|Yes| D[Inject JWT header]
130130
D --> E[Forward to UI/Backend]
131131
E --> F{AuthContext:<br/>JWT valid?}
132-
F -->|Yes| G[Set user state]
133-
F -->|No| H[Set error state]
132+
F -->|authenticated| G[Set user state]
133+
F -->|expired| I[Re-run OIDC via /oauth2/start]
134+
F -->|unsecured| J[No user, no redirect]
134135
135136
style C fill:#f96,stroke:#333
136-
style H fill:#ff9,stroke:#333
137+
style I fill:#ff9,stroke:#333
137138
```
138139

139-
**Design rationale**: The UI does not redirect on auth failure. If `getCurrentUser()` fails, it indicates a misconfiguration (oauth2-proxy should have intercepted the request) rather than a normal session expiry. The error state surfaces this for debugging rather than masking it with a redirect loop.
140+
**Design rationale**: oauth2-proxy gates access using its session cookie (valid up to `cookie-expire`, default 168h), while the UI derives the user from the forwarded id_token. These lifetimes are decoupled, so the id_token can go stale while the session cookie is still valid. To keep them aligned, oauth2-proxy *can* be configured with `cookie-refresh` (and the `offline_access` scope) to refresh the id_token — note the chart's defaults do **not** enable these (default scope is `openid profile email groups`), so operators must opt in. As a safety net regardless of refresh configuration, `getCurrentUser()` distinguishes three states:
141+
142+
- **authenticated** — valid token → set user state.
143+
- **expired**`Authorization` header present but token missing/expired → the UI re-runs the OIDC flow (`/oauth2/start`) to mint a fresh token, guarded against redirect loops.
144+
- **unsecured** — no `Authorization` header (no oauth2-proxy in front) → no user and no redirect (there is no `/oauth2` endpoint to redirect to).
140145

141146
## Service Account Fallback
142147

helm/kagent/templates/ui-deployment.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ spec:
6363
{{- if .Values.ui.auth }}
6464
- name: SSO_REDIRECT_PATH
6565
value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }}
66+
# Client components (e.g. AuthContext) can only read NEXT_PUBLIC_* vars at runtime.
67+
- name: NEXT_PUBLIC_SSO_REDIRECT_PATH
68+
value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }}
6669
{{- end }}
6770
{{- with .Values.ui.additionalForwardedHeaders }}
6871
- name: KAGENT_ADDITIONAL_FORWARDED_HEADERS
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { getCurrentUser } from "@/app/actions/auth";
2+
import { headers } from "next/headers";
3+
import { decodeJWT, isTokenExpired } from "@/lib/jwt";
4+
5+
jest.mock("next/headers", () => ({
6+
headers: jest.fn(),
7+
}));
8+
9+
jest.mock("@/lib/jwt", () => ({
10+
decodeJWT: jest.fn(),
11+
isTokenExpired: jest.fn(),
12+
}));
13+
14+
const mockedHeaders = headers as jest.Mock;
15+
const mockedDecodeJWT = decodeJWT as jest.Mock;
16+
const mockedIsTokenExpired = isTokenExpired as jest.Mock;
17+
18+
function withAuthorizationHeader(value: string | null) {
19+
mockedHeaders.mockResolvedValue({
20+
get: (name: string) => (name === "Authorization" ? value : null),
21+
});
22+
}
23+
24+
describe("getCurrentUser", () => {
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
29+
it("returns unsecured when there is no Authorization header", async () => {
30+
withAuthorizationHeader(null);
31+
32+
const result = await getCurrentUser();
33+
34+
expect(result).toEqual({ status: "unsecured", user: null });
35+
expect(mockedDecodeJWT).not.toHaveBeenCalled();
36+
});
37+
38+
it("returns unsecured when the header is not a Bearer token", async () => {
39+
withAuthorizationHeader("Basic abc123");
40+
41+
const result = await getCurrentUser();
42+
43+
expect(result).toEqual({ status: "unsecured", user: null });
44+
});
45+
46+
it("returns expired when the token cannot be decoded", async () => {
47+
withAuthorizationHeader("Bearer not-a-jwt");
48+
mockedDecodeJWT.mockReturnValue(null);
49+
50+
const result = await getCurrentUser();
51+
52+
expect(mockedDecodeJWT).toHaveBeenCalledWith("not-a-jwt");
53+
expect(result).toEqual({ status: "expired", user: null });
54+
});
55+
56+
it("returns expired when the token is decoded but expired", async () => {
57+
withAuthorizationHeader("Bearer expired.jwt.token");
58+
mockedDecodeJWT.mockReturnValue({ sub: "user-1", exp: 1 });
59+
mockedIsTokenExpired.mockReturnValue(true);
60+
61+
const result = await getCurrentUser();
62+
63+
expect(result).toEqual({ status: "expired", user: null });
64+
});
65+
66+
it("returns authenticated with the decoded claims for a valid token", async () => {
67+
const claims = { sub: "user-1", email: "user@example.com", groups: ["admins"] };
68+
withAuthorizationHeader("Bearer valid.jwt.token");
69+
mockedDecodeJWT.mockReturnValue(claims);
70+
mockedIsTokenExpired.mockReturnValue(false);
71+
72+
const result = await getCurrentUser();
73+
74+
expect(result).toEqual({ status: "authenticated", user: claims });
75+
});
76+
});

ui/src/app/actions/auth.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,32 @@ export interface CurrentUser extends Record<string, unknown> {
1111
groups?: string[];
1212
}
1313

14-
export async function getCurrentUser(): Promise<CurrentUser | null> {
14+
// authenticated → valid, non-expired token forwarded by oauth2-proxy
15+
// expired → oauth2-proxy session is still valid but the forwarded
16+
// id_token is missing/expired → the UI should re-run OIDC
17+
// unsecured → no Authorization header at all (no oauth2-proxy in front);
18+
// the UI must NOT redirect or it would loop with no /oauth2 endpoint
19+
export type AuthStatus = "authenticated" | "expired" | "unsecured";
20+
21+
export interface AuthResult {
22+
status: AuthStatus;
23+
user: CurrentUser | null;
24+
}
25+
26+
export async function getCurrentUser(): Promise<AuthResult> {
1527
const headersList = await headers();
1628
const authHeader = headersList.get("Authorization");
1729

1830
if (!authHeader?.startsWith("Bearer ")) {
19-
return null;
31+
return { status: "unsecured", user: null };
2032
}
2133

2234
const token = authHeader.slice(7);
2335
const claims = decodeJWT(token);
2436

2537
if (!claims || isTokenExpired(claims)) {
26-
return null;
38+
return { status: "expired", user: null };
2739
}
2840

29-
return claims as CurrentUser;
41+
return { status: "authenticated", user: claims as CurrentUser };
3042
}

ui/src/app/login/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default function LoginPage() {
1212
{/* Preload background image for faster rendering */}
1313
<link rel="preload" href="/login-bg.webp" as="image" type="image/webp" fetchPriority="high" />
1414

15-
<div className="login-page relative fixed inset-0 z-50 overflow-hidden bg-[#0B0B15] text-white">
15+
<div className="login-page fixed inset-0 z-50 overflow-hidden bg-[#0B0B15] text-white">
1616
<a
1717
href="#login-main"
1818
className={cn(skipToContentLinkClassName, "text-white/90")}

ui/src/contexts/AuthContext.tsx

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
"use client";
22

3-
import React, { createContext, useContext, useEffect, useState, ReactNode } from "react";
4-
import { getCurrentUser, CurrentUser } from "@/app/actions/auth";
3+
import React, { createContext, useContext, useEffect, useState, type ReactNode } from "react";
4+
import { getCurrentUser, type CurrentUser, type AuthStatus } from "@/app/actions/auth";
5+
6+
// oauth2-proxy endpoint that (re)starts the OIDC flow. Client components can only
7+
// read NEXT_PUBLIC_* env vars at runtime, so this mirrors the server-side
8+
// SSO_REDIRECT_PATH (used by the login page) via NEXT_PUBLIC_SSO_REDIRECT_PATH,
9+
// which the Helm chart injects from ui.auth.ssoRedirectPath.
10+
const SSO_REDIRECT_PATH = process.env.NEXT_PUBLIC_SSO_REDIRECT_PATH || "/oauth2/start";
11+
// Guards against redirect loops if re-auth keeps returning a stale token.
12+
const REAUTH_GUARD_KEY = "kagent_reauth_attempt";
13+
// Wide enough to cover a slow IdP round-trip so a genuinely in-flight re-auth
14+
// isn't misread as a failed loop, while still catching a fast redirect loop.
15+
const REAUTH_GUARD_WINDOW_MS = 60_000;
516

617
interface AuthContextValue {
718
user: CurrentUser | null;
19+
status: AuthStatus;
820
isLoading: boolean;
921
error: Error | null;
1022
refetch: () => Promise<void>;
@@ -14,15 +26,17 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
1426

1527
export function AuthProvider({ children }: { children: ReactNode }) {
1628
const [user, setUser] = useState<CurrentUser | null>(null);
29+
const [status, setStatus] = useState<AuthStatus>("unsecured");
1730
const [isLoading, setIsLoading] = useState(true);
1831
const [error, setError] = useState<Error | null>(null);
1932

2033
const fetchUser = async () => {
2134
setIsLoading(true);
2235
setError(null);
2336
try {
24-
const currentUser = await getCurrentUser();
25-
setUser(currentUser);
37+
const result = await getCurrentUser();
38+
setStatus(result.status);
39+
setUser(result.user);
2640
} catch (e) {
2741
setError(e instanceof Error ? e : new Error("Failed to fetch user"));
2842
} finally {
@@ -34,8 +48,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
3448
fetchUser();
3549
}, []);
3650

51+
// When oauth2-proxy's session cookie is still valid but the forwarded
52+
// id_token has expired, re-run the OIDC flow to mint a fresh token instead
53+
// of silently rendering a logged-out UI. Only triggers in secured ("expired")
54+
// mode — never in "unsecured" mode where there is no /oauth2 endpoint.
55+
useEffect(() => {
56+
if (isLoading || status !== "expired" || typeof window === "undefined") return;
57+
58+
const lastAttempt = Number(sessionStorage.getItem(REAUTH_GUARD_KEY) || "0");
59+
if (Date.now() - lastAttempt < REAUTH_GUARD_WINDOW_MS) {
60+
setError(
61+
new Error("Authentication expired and re-authentication did not refresh the session.")
62+
);
63+
return;
64+
}
65+
sessionStorage.setItem(REAUTH_GUARD_KEY, String(Date.now()));
66+
const rd = encodeURIComponent(window.location.pathname + window.location.search);
67+
window.location.assign(`${SSO_REDIRECT_PATH}?rd=${rd}`);
68+
}, [isLoading, status]);
69+
70+
useEffect(() => {
71+
if (status === "authenticated" && typeof window !== "undefined") {
72+
sessionStorage.removeItem(REAUTH_GUARD_KEY);
73+
}
74+
}, [status]);
75+
3776
return (
38-
<AuthContext.Provider value={{ user, isLoading, error, refetch: fetchUser }}>
77+
<AuthContext.Provider value={{ user, status, isLoading, error, refetch: fetchUser }}>
3978
{children}
4079
</AuthContext.Provider>
4180
);

0 commit comments

Comments
 (0)