Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions design/EP-2045-sso-session-expiry-redirect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# EP-2045: SSO session-expiry re-authentication redirect

* Issue: [#2045](https://github.com/kagent-dev/kagent/issues/2045)

## Background

kagent is frequently deployed behind [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy)
acting as an OIDC relying party. oauth2-proxy authenticates the user, maintains
its own session cookie, and forwards the user's `id_token` to the kagent UI as a
`Authorization: Bearer <jwt>` header. The UI decodes the JWT to derive the current
user (`getCurrentUser`).

Two distinct failure modes were conflated in the previous implementation, which
decoded the token and returned `CurrentUser | null`:

1. **No proxy in front of the UI** — there is no `Authorization` header at all.
This is a valid "unsecured" deployment (local dev, no SSO).
2. **Proxy session valid but forwarded `id_token` expired** — oauth2-proxy still
holds a valid session cookie, but the `id_token` it forwards has expired (its
lifetime is typically shorter than the cookie session). The JWT decodes to an
expired token.

Both previously collapsed to `null`, so the UI silently rendered a logged-out
experience. In case (2) the correct behavior is to re-run the OIDC flow against
oauth2-proxy (`/oauth2/start`) so a fresh `id_token` is minted, transparently
restoring the session. In case (1) the UI must **not** redirect — there is no
`/oauth2` endpoint, so a redirect would loop forever.

## Motivation

Users behind SSO are unexpectedly "logged out" mid-session when the forwarded
`id_token` expires, even though their oauth2-proxy session is still valid. They
must manually reload to recover. This is confusing and looks like a kagent bug.

### Goals

- Distinguish three auth states in the UI: `authenticated`, `expired`, `unsecured`.
- On `expired`, transparently re-run the OIDC flow (redirect to the oauth2-proxy
re-auth endpoint) so a fresh token is obtained without user intervention.
- Never redirect in `unsecured` mode (no proxy), to avoid an infinite loop.
- Guard against redirect loops if re-auth keeps returning a stale token.

### Non-Goals

- Changing the server-side token validation or the controller/HTTP server auth.
- Implementing a kagent-native OIDC client (oauth2-proxy remains the RP).
- Refresh-token handling inside the UI (delegated to oauth2-proxy).

## Implementation Details

### Auth status model (`ui/src/app/actions/auth.ts`)

`getCurrentUser()` now returns an `AuthResult` instead of `CurrentUser | null`:

```ts
export type AuthStatus = "authenticated" | "expired" | "unsecured";

export interface AuthResult {
status: AuthStatus;
user: CurrentUser | null;
}
```

- No `Authorization: Bearer` header → `{ status: "unsecured", user: null }`.
- Header present but token missing/expired (`isTokenExpired`) → `{ status: "expired", user: null }`.
- Valid token → `{ status: "authenticated", user: claims }`.

### Re-auth redirect (`ui/src/contexts/AuthContext.tsx`)

`AuthProvider` exposes `status` alongside `user`. When `status === "expired"`
(and only then) it redirects the browser to the oauth2-proxy re-auth endpoint,
preserving the current location for return:

```ts
const SSO_REAUTH_PATH = process.env.NEXT_PUBLIC_SSO_REAUTH_PATH || "/oauth2/start";
const REAUTH_GUARD_KEY = "kagent_reauth_attempt";
const REAUTH_GUARD_WINDOW_MS = 10_000;
// ...redirect to `${SSO_REAUTH_PATH}?rd=${encodeURIComponent(path + search)}`
```

A `sessionStorage` guard (`REAUTH_GUARD_WINDOW_MS`) prevents a redirect loop: if a
re-auth attempt happened within the window and the token is still expired, the UI
surfaces an error instead of redirecting again. The guard is cleared on a
successful `authenticated` result.

### UI surface

- `ui/src/components/UserMenu.tsx` — reflects the new status (e.g. distinguishes a
logged-out/unsecured menu from an authenticated one).
- `ui/src/app/login/page.tsx` — the branded login page (a server component) reads
the server-side `SSO_REDIRECT_PATH`. `AuthContext` (a client component) reads the
client-exposed `NEXT_PUBLIC_SSO_REDIRECT_PATH`; both default to `/oauth2/start`
and the Helm chart injects both from `ui.auth.ssoRedirectPath`.
- `docs/OIDC_PROXY_AUTH_ARCHITECTURE.md` — documents the three states and the
re-auth flow.

### Configuration

| Env var | Default | Purpose |
|---------|---------|---------|
| `NEXT_PUBLIC_SSO_REAUTH_PATH` | `/oauth2/start` | oauth2-proxy endpoint that restarts the OIDC flow. |

## Test Plan

- **Unit (UI):** `getCurrentUser` returns the correct `AuthStatus` for: no header,
expired token, valid token. `AuthProvider` redirects only on `expired`, never on
`unsecured`, and respects the loop guard.
- **Manual / e2e:** Deploy behind oauth2-proxy with a short `id_token` lifetime;
confirm that on token expiry the UI redirects to `/oauth2/start?rd=...` and
returns to the same page authenticated, without a logout flash. Confirm that a
no-proxy deployment never redirects.

## Alternatives

- **Silent background refresh via fetch to `/oauth2/auth`** — more complex, and
oauth2-proxy already mints a fresh token on a full `/oauth2/start` round-trip.
- **Server-side redirect (middleware)** — harder to distinguish unsecured vs
expired without the decoded claims available client-side, and risks loops.

## Open Questions

- Should the re-auth guard window be configurable per deployment?
- Should `expired` optionally render a non-blocking toast ("re-authenticating…")
before the redirect for slow networks?
13 changes: 9 additions & 4 deletions docs/OIDC_PROXY_AUTH_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,19 @@ flowchart TD
B -->|Yes| D[Inject JWT header]
D --> E[Forward to UI/Backend]
E --> F{AuthContext:<br/>JWT valid?}
F -->|Yes| G[Set user state]
F -->|No| H[Set error state]
F -->|authenticated| G[Set user state]
F -->|expired| I[Re-run OIDC via /oauth2/start]
F -->|unsecured| J[No user, no redirect]

style C fill:#f96,stroke:#333
style H fill:#ff9,stroke:#333
style I fill:#ff9,stroke:#333
```

**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.
**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:

- **authenticated** — valid token → set user state.
- **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.
- **unsecured** — no `Authorization` header (no oauth2-proxy in front) → no user and no redirect (there is no `/oauth2` endpoint to redirect to).

## Service Account Fallback

Expand Down
3 changes: 3 additions & 0 deletions helm/kagent/templates/ui-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ spec:
{{- if .Values.ui.auth }}
- name: SSO_REDIRECT_PATH
value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }}
# Client components (e.g. AuthContext) can only read NEXT_PUBLIC_* vars at runtime.
- name: NEXT_PUBLIC_SSO_REDIRECT_PATH
value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }}
{{- end }}
{{- with .Values.ui.additionalForwardedHeaders }}
- name: KAGENT_ADDITIONAL_FORWARDED_HEADERS
Expand Down
76 changes: 76 additions & 0 deletions ui/src/app/actions/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { getCurrentUser } from "@/app/actions/auth";
import { headers } from "next/headers";
import { decodeJWT, isTokenExpired } from "@/lib/jwt";

jest.mock("next/headers", () => ({
headers: jest.fn(),
}));

jest.mock("@/lib/jwt", () => ({
decodeJWT: jest.fn(),
isTokenExpired: jest.fn(),
}));

const mockedHeaders = headers as jest.Mock;
const mockedDecodeJWT = decodeJWT as jest.Mock;
const mockedIsTokenExpired = isTokenExpired as jest.Mock;

function withAuthorizationHeader(value: string | null) {
mockedHeaders.mockResolvedValue({
get: (name: string) => (name === "Authorization" ? value : null),
});
}

describe("getCurrentUser", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("returns unsecured when there is no Authorization header", async () => {
withAuthorizationHeader(null);

const result = await getCurrentUser();

expect(result).toEqual({ status: "unsecured", user: null });
expect(mockedDecodeJWT).not.toHaveBeenCalled();
});

it("returns unsecured when the header is not a Bearer token", async () => {
withAuthorizationHeader("Basic abc123");

const result = await getCurrentUser();

expect(result).toEqual({ status: "unsecured", user: null });
});

it("returns expired when the token cannot be decoded", async () => {
withAuthorizationHeader("Bearer not-a-jwt");
mockedDecodeJWT.mockReturnValue(null);

const result = await getCurrentUser();

expect(mockedDecodeJWT).toHaveBeenCalledWith("not-a-jwt");
expect(result).toEqual({ status: "expired", user: null });
});

it("returns expired when the token is decoded but expired", async () => {
withAuthorizationHeader("Bearer expired.jwt.token");
mockedDecodeJWT.mockReturnValue({ sub: "user-1", exp: 1 });
mockedIsTokenExpired.mockReturnValue(true);

const result = await getCurrentUser();

expect(result).toEqual({ status: "expired", user: null });
});

it("returns authenticated with the decoded claims for a valid token", async () => {
const claims = { sub: "user-1", email: "user@example.com", groups: ["admins"] };
withAuthorizationHeader("Bearer valid.jwt.token");
mockedDecodeJWT.mockReturnValue(claims);
mockedIsTokenExpired.mockReturnValue(false);

const result = await getCurrentUser();

expect(result).toEqual({ status: "authenticated", user: claims });
});
});
20 changes: 16 additions & 4 deletions ui/src/app/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,32 @@ export interface CurrentUser extends Record<string, unknown> {
groups?: string[];
}

export async function getCurrentUser(): Promise<CurrentUser | null> {
// authenticated → valid, non-expired token forwarded by oauth2-proxy
// expired → oauth2-proxy session is still valid but the forwarded
// id_token is missing/expired → the UI should re-run OIDC
// unsecured → no Authorization header at all (no oauth2-proxy in front);
// the UI must NOT redirect or it would loop with no /oauth2 endpoint
export type AuthStatus = "authenticated" | "expired" | "unsecured";

export interface AuthResult {
status: AuthStatus;
user: CurrentUser | null;
}

export async function getCurrentUser(): Promise<AuthResult> {
const headersList = await headers();
const authHeader = headersList.get("Authorization");

if (!authHeader?.startsWith("Bearer ")) {
return null;
return { status: "unsecured", user: null };
}

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

if (!claims || isTokenExpired(claims)) {
return null;
return { status: "expired", user: null };
}

return claims as CurrentUser;
return { status: "authenticated", user: claims as CurrentUser };
}
2 changes: 1 addition & 1 deletion ui/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function LoginPage() {
{/* Preload background image for faster rendering */}
<link rel="preload" href="/login-bg.webp" as="image" type="image/webp" fetchPriority="high" />

<div className="login-page relative fixed inset-0 z-50 overflow-hidden bg-[#0B0B15] text-white">
<div className="login-page fixed inset-0 z-50 overflow-hidden bg-[#0B0B15] text-white">
<a
href="#login-main"
className={cn(skipToContentLinkClassName, "text-white/90")}
Expand Down
49 changes: 44 additions & 5 deletions ui/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
"use client";

import React, { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { getCurrentUser, CurrentUser } from "@/app/actions/auth";
import React, { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { getCurrentUser, type CurrentUser, type AuthStatus } from "@/app/actions/auth";

// oauth2-proxy endpoint that (re)starts the OIDC flow. Client components can only
// read NEXT_PUBLIC_* env vars at runtime, so this mirrors the server-side
// SSO_REDIRECT_PATH (used by the login page) via NEXT_PUBLIC_SSO_REDIRECT_PATH,
// which the Helm chart injects from ui.auth.ssoRedirectPath.
const SSO_REDIRECT_PATH = process.env.NEXT_PUBLIC_SSO_REDIRECT_PATH || "/oauth2/start";
// Guards against redirect loops if re-auth keeps returning a stale token.
const REAUTH_GUARD_KEY = "kagent_reauth_attempt";
// Wide enough to cover a slow IdP round-trip so a genuinely in-flight re-auth
// isn't misread as a failed loop, while still catching a fast redirect loop.
const REAUTH_GUARD_WINDOW_MS = 60_000;

interface AuthContextValue {
user: CurrentUser | null;
status: AuthStatus;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
Expand All @@ -14,15 +26,17 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);

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

const fetchUser = async () => {
setIsLoading(true);
setError(null);
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
const result = await getCurrentUser();
setStatus(result.status);
setUser(result.user);
} catch (e) {
setError(e instanceof Error ? e : new Error("Failed to fetch user"));
} finally {
Expand All @@ -34,8 +48,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
fetchUser();
}, []);

// When oauth2-proxy's session cookie is still valid but the forwarded
// id_token has expired, re-run the OIDC flow to mint a fresh token instead
// of silently rendering a logged-out UI. Only triggers in secured ("expired")
// mode — never in "unsecured" mode where there is no /oauth2 endpoint.
useEffect(() => {
if (isLoading || status !== "expired" || typeof window === "undefined") return;

const lastAttempt = Number(sessionStorage.getItem(REAUTH_GUARD_KEY) || "0");
if (Date.now() - lastAttempt < REAUTH_GUARD_WINDOW_MS) {
setError(
new Error("Authentication expired and re-authentication did not refresh the session.")
);
return;
}
sessionStorage.setItem(REAUTH_GUARD_KEY, String(Date.now()));
const rd = encodeURIComponent(window.location.pathname + window.location.search);
window.location.assign(`${SSO_REDIRECT_PATH}?rd=${rd}`);
}, [isLoading, status]);

useEffect(() => {
if (status === "authenticated" && typeof window !== "undefined") {
sessionStorage.removeItem(REAUTH_GUARD_KEY);
}
}, [status]);

return (
<AuthContext.Provider value={{ user, isLoading, error, refetch: fetchUser }}>
<AuthContext.Provider value={{ user, status, isLoading, error, refetch: fetchUser }}>
{children}
</AuthContext.Provider>
);
Expand Down
Loading
Loading