Skip to content

Commit 388628c

Browse files
committed
fix(ui): address PR review feedback for SSO re-auth flow
Rename getCurrentUser to getAuthResult, store AuthResult as single state, use location.replace for re-auth redirect, restore login page relative positioning, and clear error on successful authentication. Signed-off-by: Dmytro Rashko <dmitriy.rashko@amdocs.com>
1 parent 4e0771b commit 388628c

7 files changed

Lines changed: 42 additions & 35 deletions

File tree

design/EP-2045-sso-session-expiry-redirect.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ kagent is frequently deployed behind [oauth2-proxy](https://github.com/oauth2-pr
88
acting as an OIDC relying party. oauth2-proxy authenticates the user, maintains
99
its own session cookie, and forwards the user's `id_token` to the kagent UI as a
1010
`Authorization: Bearer <jwt>` header. The UI decodes the JWT to derive the current
11-
user (`getCurrentUser`).
11+
user (`getAuthResult`).
1212

1313
Two distinct failure modes were conflated in the previous implementation, which
1414
decoded the token and returned `CurrentUser | null`:
@@ -50,7 +50,7 @@ must manually reload to recover. This is confusing and looks like a kagent bug.
5050

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

53-
`getCurrentUser()` now returns an `AuthResult` instead of `CurrentUser | null`:
53+
`getAuthResult()` now returns an `AuthResult` instead of `CurrentUser | null`:
5454

5555
```ts
5656
export type AuthStatus = "authenticated" | "expired" | "unsecured";
@@ -102,7 +102,7 @@ successful `authenticated` result.
102102

103103
## Test Plan
104104

105-
- **Unit (UI):** `getCurrentUser` returns the correct `AuthStatus` for: no header,
105+
- **Unit (UI):** `getAuthResult` returns the correct `AuthStatus` for: no header,
106106
expired token, valid token. `AuthProvider` redirects only on `expired`, never on
107107
`unsecured`, and respects the loop guard.
108108
- **Manual / e2e:** Deploy behind oauth2-proxy with a short `id_token` lifetime;

docs/OIDC_PROXY_AUTH_ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ flowchart TB
5151
subgraph UI["UI Layer (Next.js)"]
5252
LoginPage["/login Page<br/>SSO redirect button"]
5353
AuthContext["AuthContext Provider<br/>- User state management<br/>- Loading/error states"]
54-
AuthActions["Server Actions<br/>getCurrentUser()"]
54+
AuthActions["Server Actions<br/>getAuthResult()"]
5555
JWTLib["JWT Library<br/>- Decode tokens<br/>- Check expiry"]
5656
AuthLib["Auth Library<br/>- Header forwarding"]
5757
end
@@ -137,7 +137,7 @@ flowchart TD
137137
style I fill:#ff9,stroke:#333
138138
```
139139

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:
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, `getAuthResult()` distinguishes three states:
141141

142142
- **authenticated** — valid token → set user state.
143143
- **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.

ui/src/app/actions/__tests__/auth.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCurrentUser } from "@/app/actions/auth";
1+
import { getAuthResult } from "@/app/actions/auth";
22
import { headers } from "next/headers";
33
import { decodeJWT, isTokenExpired } from "@/lib/jwt";
44

@@ -21,15 +21,15 @@ function withAuthorizationHeader(value: string | null) {
2121
});
2222
}
2323

24-
describe("getCurrentUser", () => {
24+
describe("getAuthResult", () => {
2525
beforeEach(() => {
2626
jest.clearAllMocks();
2727
});
2828

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

32-
const result = await getCurrentUser();
32+
const result = await getAuthResult();
3333

3434
expect(result).toEqual({ status: "unsecured", user: null });
3535
expect(mockedDecodeJWT).not.toHaveBeenCalled();
@@ -38,7 +38,7 @@ describe("getCurrentUser", () => {
3838
it("returns unsecured when the header is not a Bearer token", async () => {
3939
withAuthorizationHeader("Basic abc123");
4040

41-
const result = await getCurrentUser();
41+
const result = await getAuthResult();
4242

4343
expect(result).toEqual({ status: "unsecured", user: null });
4444
});
@@ -47,7 +47,7 @@ describe("getCurrentUser", () => {
4747
withAuthorizationHeader("Bearer not-a-jwt");
4848
mockedDecodeJWT.mockReturnValue(null);
4949

50-
const result = await getCurrentUser();
50+
const result = await getAuthResult();
5151

5252
expect(mockedDecodeJWT).toHaveBeenCalledWith("not-a-jwt");
5353
expect(result).toEqual({ status: "expired", user: null });
@@ -58,7 +58,7 @@ describe("getCurrentUser", () => {
5858
mockedDecodeJWT.mockReturnValue({ sub: "user-1", exp: 1 });
5959
mockedIsTokenExpired.mockReturnValue(true);
6060

61-
const result = await getCurrentUser();
61+
const result = await getAuthResult();
6262

6363
expect(result).toEqual({ status: "expired", user: null });
6464
});
@@ -69,7 +69,7 @@ describe("getCurrentUser", () => {
6969
mockedDecodeJWT.mockReturnValue(claims);
7070
mockedIsTokenExpired.mockReturnValue(false);
7171

72-
const result = await getCurrentUser();
72+
const result = await getAuthResult();
7373

7474
expect(result).toEqual({ status: "authenticated", user: claims });
7575
});

ui/src/app/actions/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface AuthResult {
2323
user: CurrentUser | null;
2424
}
2525

26-
export async function getCurrentUser(): Promise<AuthResult> {
26+
export async function getAuthResult(): Promise<AuthResult> {
2727
const headersList = await headers();
2828
const authHeader = headersList.get("Authorization");
2929

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 fixed inset-0 z-50 overflow-hidden bg-[#0B0B15] text-white">
15+
<div className="login-page relative 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: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import React, { createContext, useContext, useEffect, useState, type ReactNode } from "react";
4-
import { getCurrentUser, type CurrentUser, type AuthStatus } from "@/app/actions/auth";
4+
import { getAuthResult, type AuthResult, type CurrentUser, type AuthStatus } from "@/app/actions/auth";
55

66
// oauth2-proxy endpoint that (re)starts the OIDC flow. Client components can only
77
// read NEXT_PUBLIC_* env vars at runtime, so this mirrors the server-side
@@ -25,18 +25,15 @@ interface AuthContextValue {
2525
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
2626

2727
export function AuthProvider({ children }: { children: ReactNode }) {
28-
const [user, setUser] = useState<CurrentUser | null>(null);
29-
const [status, setStatus] = useState<AuthStatus>("unsecured");
28+
const [authResult, setAuthResult] = useState<AuthResult>({ status: "unsecured", user: null });
3029
const [isLoading, setIsLoading] = useState(true);
3130
const [error, setError] = useState<Error | null>(null);
3231

3332
const fetchUser = async () => {
3433
setIsLoading(true);
3534
setError(null);
3635
try {
37-
const result = await getCurrentUser();
38-
setStatus(result.status);
39-
setUser(result.user);
36+
setAuthResult(await getAuthResult());
4037
} catch (e) {
4138
setError(e instanceof Error ? e : new Error("Failed to fetch user"));
4239
} finally {
@@ -53,7 +50,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
5350
// of silently rendering a logged-out UI. Only triggers in secured ("expired")
5451
// mode — never in "unsecured" mode where there is no /oauth2 endpoint.
5552
useEffect(() => {
56-
if (isLoading || status !== "expired" || typeof window === "undefined") return;
53+
if (isLoading || authResult.status !== "expired" || typeof window === "undefined") return;
5754

5855
const lastAttempt = Number(sessionStorage.getItem(REAUTH_GUARD_KEY) || "0");
5956
if (Date.now() - lastAttempt < REAUTH_GUARD_WINDOW_MS) {
@@ -64,17 +61,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
6461
}
6562
sessionStorage.setItem(REAUTH_GUARD_KEY, String(Date.now()));
6663
const rd = encodeURIComponent(window.location.pathname + window.location.search);
67-
window.location.assign(`${SSO_REDIRECT_PATH}?rd=${rd}`);
68-
}, [isLoading, status]);
64+
window.location.replace(`${SSO_REDIRECT_PATH}?rd=${rd}`);
65+
}, [isLoading, authResult.status]);
6966

7067
useEffect(() => {
71-
if (status === "authenticated" && typeof window !== "undefined") {
68+
if (authResult.status === "authenticated" && typeof window !== "undefined") {
7269
sessionStorage.removeItem(REAUTH_GUARD_KEY);
70+
setError(null);
7371
}
74-
}, [status]);
72+
}, [authResult.status]);
7573

7674
return (
77-
<AuthContext.Provider value={{ user, status, isLoading, error, refetch: fetchUser }}>
75+
<AuthContext.Provider
76+
value={{
77+
user: authResult.user,
78+
status: authResult.status,
79+
isLoading,
80+
error,
81+
refetch: fetchUser,
82+
}}
83+
>
7884
{children}
7985
</AuthContext.Provider>
8086
);

ui/src/contexts/__tests__/AuthContext.test.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import React from "react";
22
import { render, screen, waitFor } from "@testing-library/react";
33
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
4-
import { getCurrentUser, type AuthResult } from "@/app/actions/auth";
4+
import { getAuthResult, type AuthResult } from "@/app/actions/auth";
55

66
jest.mock("@/app/actions/auth", () => ({
7-
getCurrentUser: jest.fn(),
7+
getAuthResult: jest.fn(),
88
}));
99

10-
const mockedGetCurrentUser = getCurrentUser as jest.Mock;
10+
const mockedGetAuthResult = getAuthResult as jest.Mock;
1111

1212
const REAUTH_GUARD_KEY = "kagent_reauth_attempt";
1313

@@ -31,15 +31,15 @@ function renderProvider() {
3131
}
3232

3333
function setResult(result: AuthResult) {
34-
mockedGetCurrentUser.mockResolvedValue(result);
34+
mockedGetAuthResult.mockResolvedValue(result);
3535
}
3636

37-
// jsdom fully hardens window.location (it is non-configurable and assign is
38-
// read-only), so the redirect call (window.location.assign) cannot be spied on
37+
// jsdom fully hardens window.location (it is non-configurable and replace is
38+
// read-only), so the redirect call (window.location.replace) cannot be spied on
3939
// here. Instead we assert the observable contract that gates the redirect: the
40-
// sessionStorage re-auth guard, written immediately before assign() is invoked
40+
// sessionStorage re-auth guard, written immediately before replace() is invoked
4141
// ("guard written" == "redirect attempted"). console.error is silenced to drop
42-
// jsdom's expected "Not implemented: navigation" noise from the assign() call.
42+
// jsdom's expected "Not implemented: navigation" noise from the replace() call.
4343
describe("AuthProvider re-auth behavior", () => {
4444
let consoleErrorSpy: jest.SpyInstance;
4545

@@ -92,7 +92,7 @@ describe("AuthProvider re-auth behavior", () => {
9292
expect(sessionStorage.getItem(REAUTH_GUARD_KEY)).toBe(previousAttempt);
9393
});
9494

95-
it("clears the re-auth guard once authenticated", async () => {
95+
it("clears the re-auth guard and error once authenticated", async () => {
9696
sessionStorage.setItem(REAUTH_GUARD_KEY, String(Date.now()));
9797
setResult({ status: "authenticated", user: { sub: "user-1" } });
9898

@@ -102,5 +102,6 @@ describe("AuthProvider re-auth behavior", () => {
102102
expect(screen.getByTestId("status").textContent).toBe("authenticated");
103103
});
104104
expect(sessionStorage.getItem(REAUTH_GUARD_KEY)).toBeNull();
105+
expect(screen.getByTestId("error").textContent).toBe("");
105106
});
106107
});

0 commit comments

Comments
 (0)