Skip to content

Commit 6ae57b7

Browse files
frontend: Handle token expiry and add session expiry warning fixes #5112
Add a token-expiry notification banner so users are warned before their session ends and automatically logged out when the token expires. Root cause ---------- When a cluster uses a short-lived bearer token (e.g. a Kubernetes TokenRequest token with expirationSeconds=600), neither the frontend nor the backend provided any user-facing indication that the session was about to end. The only existing mechanism (OIDCTokenRefreshMiddleware) silently refreshes tokens within 10 seconds of expiry for OIDC clusters, but gives no signal to the client and does nothing for non-OIDC bearer tokens. Changes ------- backend/pkg/auth/auth.go - writeMeResponse now accepts a tokenExpiry time.Time and serialises it as a Unix timestamp under "tokenExpiry" in the 200 OK JSON body so the frontend can calculate time-to-expiry without reading httpOnly cookies. - HandleMe extracts the expiry value before the expired-token check so it can be forwarded to writeMeResponse. backend/pkg/auth/auth_test.go - TestHandleMe_IncludesTokenExpiry: verifies that the tokenExpiry field in the 200 response equals the exp claim in the JWT. frontend/src/lib/auth.ts - ClusterMeResponse interface documents the tokenExpiry field. - ClusterMeResult discriminated union cleanly separates "token expired" from "network error" from "success". - fetchClusterMe fetches /clusters/:cluster/me, maps 401 to tokenExpired:true, and wraps other errors as null data. frontend/src/components/App/TokenExpiryNotification.tsx (new) - PureTokenExpiryNotification polls fetchClusterMeFn every 60 s (injectable for testing). - Shows a warning banner with a countdown when <2 min remain. - Shows an error banner and calls logout() when the token has expired. - Suppressed on login/token/settings routes (same pattern as AlertNotification). frontend/src/components/App/Layout.tsx - Mounts <TokenExpiryNotification /> alongside <AlertNotification />.
1 parent 55a5043 commit 6ae57b7

5 files changed

Lines changed: 327 additions & 5 deletions

File tree

backend/pkg/auth/auth.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,8 @@ func HandleMe(opts MeHandlerOptions) http.HandlerFunc {
327327
return
328328
}
329329

330-
if expiry, err := GetExpiryUnixTimeUTC(claims); err != nil || time.Now().After(expiry) {
330+
expiry, err := GetExpiryUnixTimeUTC(claims)
331+
if err != nil || time.Now().After(expiry) {
331332
writeMeJSON(w, http.StatusUnauthorized, map[string]interface{}{"message": "token expired"})
332333
return
333334
}
@@ -336,7 +337,7 @@ func HandleMe(opts MeHandlerOptions) http.HandlerFunc {
336337
email := stringValueFromJMESPaths(claims, compiledEmailPaths)
337338
groups := stringSliceFromJMESPaths(claims, compiledGroupsPaths)
338339

339-
writeMeResponse(w, username, email, groups, userInfoURL)
340+
writeMeResponse(w, username, email, groups, userInfoURL, expiry)
340341
}
341342
}
342343

@@ -356,13 +357,21 @@ func parseClaimsFromToken(token string) (map[string]interface{}, int, string) {
356357
}
357358

358359
// writeMeResponse serializes the identity payload with the standard cache-busting headers.
359-
func writeMeResponse(w http.ResponseWriter, username, email string, groups []string, userInfoURL string) {
360-
writeMeJSON(w, http.StatusOK, map[string]interface{}{
360+
// tokenExpiry is the token's expiry time; if non-zero it is included so the frontend
361+
// can warn users before their session ends.
362+
func writeMeResponse(w http.ResponseWriter, username, email string, groups []string, userInfoURL string, tokenExpiry time.Time) {
363+
payload := map[string]interface{}{
361364
"username": username,
362365
"email": email,
363366
"groups": groups,
364367
"userInfoURL": userInfoURL,
365-
})
368+
}
369+
370+
if !tokenExpiry.IsZero() {
371+
payload["tokenExpiry"] = tokenExpiry.Unix()
372+
}
373+
374+
writeMeJSON(w, http.StatusOK, payload)
366375
}
367376

368377
// writeMeJSON sets the standard cache-control headers used by /me responses and writes the JSON payload.

backend/pkg/auth/auth_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,3 +1027,38 @@ func TestHandleMe_MissingCookie(t *testing.T) {
10271027
assert.Equal(t, "no-store, no-cache, must-revalidate, private", rr.Header().Get("Cache-Control"))
10281028
assert.Equal(t, "Cookie", rr.Header().Get("Vary"))
10291029
}
1030+
1031+
func TestHandleMe_IncludesTokenExpiry(t *testing.T) {
1032+
t.Parallel()
1033+
1034+
futureExpiry := time.Now().Add(time.Hour).Unix()
1035+
claims := map[string]interface{}{
1036+
"preferred_username": "alice",
1037+
"exp": float64(futureExpiry),
1038+
}
1039+
1040+
token := makeTestToken(t, claims)
1041+
1042+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/clusters/test/me", nil)
1043+
req = mux.SetURLVars(req, map[string]string{"clusterName": "test"})
1044+
req.Header.Set("Authorization", "Bearer "+token)
1045+
1046+
rr := httptest.NewRecorder()
1047+
1048+
handler := auth.HandleMe(auth.MeHandlerOptions{
1049+
UsernamePaths: "preferred_username",
1050+
})
1051+
1052+
handler(rr, req)
1053+
1054+
require.Equal(t, http.StatusOK, rr.Code)
1055+
1056+
var got struct {
1057+
Username string `json:"username"`
1058+
TokenExpiry int64 `json:"tokenExpiry"`
1059+
}
1060+
1061+
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
1062+
assert.Equal(t, "alice", got.Username)
1063+
assert.Equal(t, futureExpiry, got.TokenExpiry, "tokenExpiry should match the JWT exp claim as a Unix timestamp")
1064+
}

frontend/src/components/App/Layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import DetailsDrawer from '../common/Resource/DetailsDrawer';
4747
import Sidebar, { NavigationTabs } from '../Sidebar';
4848
import RouteSwitcher from './RouteSwitcher';
4949
import ShortcutsSettings from './Settings/ShortcutsSettings';
50+
import TokenExpiryNotification from './TokenExpiryNotification';
5051
import TopBar from './TopBar';
5152
import VersionDialog from './VersionDialog';
5253

@@ -334,6 +335,7 @@ export default function Layout({}: LayoutProps) {
334335
<ClusterNotFoundPopup key={clusterName} cluster={clusterName} />
335336
))}
336337
<AlertNotification />
338+
<TokenExpiryNotification />
337339
<Box sx={{ height: '100%' }}>
338340
<Div />
339341
<Container {...containerProps} sx={{ height: '100%' }}>
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Alert from '@mui/material/Alert';
18+
import Button from '@mui/material/Button';
19+
import Typography from '@mui/material/Typography';
20+
import React from 'react';
21+
import { useTranslation } from 'react-i18next';
22+
import { matchPath, useLocation } from 'react-router-dom';
23+
import { fetchClusterMe, logout } from '../../lib/auth';
24+
import type { ClusterMeResult } from '../../lib/auth';
25+
import { getCluster } from '../../lib/cluster';
26+
import { getRoute } from '../../lib/router/getRoute';
27+
import { getRoutePath } from '../../lib/router/getRoutePath';
28+
29+
/** How often to poll the /clusters/:cluster/me endpoint (ms). */
30+
const POLL_INTERVAL_MS = 60 * 1000;
31+
32+
/** Show a warning banner when fewer than this many seconds remain before expiry. */
33+
const WARNING_BEFORE_EXPIRY_SECONDS = 2 * 60;
34+
35+
/** Routes where the banner is suppressed — these pages handle auth state themselves. */
36+
const ROUTES_WITHOUT_EXPIRY_CHECK = ['login', 'token', 'settingsCluster'];
37+
38+
export interface PureTokenExpiryNotificationProps {
39+
/** Injected fetch function so tests can control responses without hitting the network. */
40+
fetchClusterMeFn: (cluster: string) => Promise<ClusterMeResult>;
41+
}
42+
43+
/**
44+
* Polls the Headlamp /clusters/:cluster/me endpoint and shows a banner when the
45+
* session token is about to expire or has already expired.
46+
*
47+
* Exported as `PureTokenExpiryNotification` so it can be unit-tested with a
48+
* mocked fetch function.
49+
*/
50+
export function PureTokenExpiryNotification({
51+
fetchClusterMeFn,
52+
}: PureTokenExpiryNotificationProps) {
53+
const { t } = useTranslation();
54+
const { pathname } = useLocation();
55+
56+
const [tokenExpiry, setTokenExpiry] = React.useState<number | null>(null);
57+
const [tokenExpired, setTokenExpired] = React.useState(false);
58+
59+
// Restart the poller whenever the route changes (covers cluster switches too).
60+
React.useEffect(() => {
61+
setTokenExpiry(null);
62+
setTokenExpired(false);
63+
64+
const cluster = getCluster();
65+
if (!cluster) {
66+
return;
67+
}
68+
69+
let mounted = true;
70+
71+
const check = async () => {
72+
if (!mounted) return;
73+
const result = await fetchClusterMeFn(cluster);
74+
if (!mounted) return;
75+
76+
if (result.tokenExpired) {
77+
setTokenExpired(true);
78+
} else if (result.data?.tokenExpiry != null) {
79+
setTokenExpiry(result.data.tokenExpiry);
80+
}
81+
};
82+
83+
// Run once immediately, then on the regular interval.
84+
check();
85+
const id = setInterval(check, POLL_INTERVAL_MS);
86+
87+
return () => {
88+
mounted = false;
89+
clearInterval(id);
90+
};
91+
// eslint-disable-next-line react-hooks/exhaustive-deps
92+
}, [pathname]);
93+
94+
// Auto-logout as soon as the backend confirms the token is expired.
95+
React.useEffect(() => {
96+
if (!tokenExpired) return;
97+
const cluster = getCluster();
98+
if (cluster) {
99+
logout(cluster);
100+
}
101+
}, [tokenExpired]);
102+
103+
const showOnRoute = React.useMemo(() => {
104+
for (const routeName of ROUTES_WITHOUT_EXPIRY_CHECK) {
105+
const maybeRoute = getRoute(routeName);
106+
if (!maybeRoute) continue;
107+
if (matchPath(pathname, getRoutePath(maybeRoute))?.isExact) return false;
108+
}
109+
return true;
110+
}, [pathname]);
111+
112+
if (!showOnRoute || !getCluster()) {
113+
return null;
114+
}
115+
116+
if (tokenExpired) {
117+
return (
118+
<Alert
119+
variant="filled"
120+
severity="error"
121+
sx={theme => ({
122+
color: theme.palette.common.white,
123+
background: theme.palette.error.main,
124+
textAlign: 'center',
125+
display: 'flex',
126+
paddingTop: theme.spacing(0.5),
127+
paddingBottom: theme.spacing(1),
128+
paddingRight: theme.spacing(3),
129+
justifyContent: 'center',
130+
position: 'fixed',
131+
zIndex: theme.zIndex.snackbar + 1,
132+
top: '0',
133+
alignItems: 'center',
134+
left: '50%',
135+
width: 'auto',
136+
transform: 'translateX(-50%)',
137+
})}
138+
>
139+
<Typography
140+
variant="body2"
141+
sx={theme => ({
142+
paddingTop: theme.spacing(0.5),
143+
fontWeight: 'bold',
144+
fontSize: '16px',
145+
})}
146+
>
147+
{t('translation|Session expired. Logging out…')}
148+
</Typography>
149+
</Alert>
150+
);
151+
}
152+
153+
const now = Math.floor(Date.now() / 1000);
154+
const secondsLeft = tokenExpiry !== null ? tokenExpiry - now : null;
155+
const isExpiring =
156+
secondsLeft !== null && secondsLeft > 0 && secondsLeft <= WARNING_BEFORE_EXPIRY_SECONDS;
157+
158+
if (!isExpiring) {
159+
return null;
160+
}
161+
162+
const minutes = Math.floor(secondsLeft! / 60);
163+
const seconds = secondsLeft! % 60;
164+
const timeStr = `${minutes}:${String(seconds).padStart(2, '0')}`;
165+
166+
return (
167+
<Alert
168+
variant="filled"
169+
severity="warning"
170+
sx={theme => ({
171+
color: theme.palette.common.white,
172+
background: theme.palette.warning.main,
173+
textAlign: 'center',
174+
display: 'flex',
175+
paddingTop: theme.spacing(0.5),
176+
paddingBottom: theme.spacing(1),
177+
paddingRight: theme.spacing(3),
178+
justifyContent: 'center',
179+
position: 'fixed',
180+
zIndex: theme.zIndex.snackbar + 1,
181+
top: '0',
182+
alignItems: 'center',
183+
left: '50%',
184+
width: 'auto',
185+
transform: 'translateX(-50%)',
186+
})}
187+
action={
188+
<Button
189+
size="small"
190+
sx={theme => ({
191+
color: theme.palette.warning.main,
192+
borderColor: theme.palette.warning.main,
193+
background: theme.palette.common.white,
194+
lineHeight: theme.typography.body2.lineHeight,
195+
'&:hover': {
196+
color: theme.palette.common.white,
197+
borderColor: theme.palette.common.white,
198+
background: theme.palette.warning.dark,
199+
},
200+
})}
201+
onClick={() => {
202+
const cluster = getCluster();
203+
if (cluster) {
204+
logout(cluster);
205+
}
206+
}}
207+
>
208+
{t('translation|Log out')}
209+
</Button>
210+
}
211+
>
212+
<Typography
213+
variant="body2"
214+
sx={theme => ({
215+
paddingTop: theme.spacing(0.5),
216+
fontWeight: 'bold',
217+
fontSize: '16px',
218+
})}
219+
>
220+
{t('translation|Session expires in {{time}}', { time: timeStr })}
221+
</Typography>
222+
</Alert>
223+
);
224+
}
225+
226+
export default function TokenExpiryNotification() {
227+
return <PureTokenExpiryNotification fetchClusterMeFn={fetchClusterMe} />;
228+
}

frontend/src/lib/auth.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import { Base64 } from 'js-base64';
2222
import { getHeadlampAPIHeaders } from '../helpers/getHeadlampAPIHeaders';
2323
import store from '../redux/stores/store';
24+
import { ApiError } from './k8s/api/v2/ApiError';
2425
import { backendFetch } from './k8s/api/v2/fetch';
2526
import { queryClient } from './queryClient';
2627

@@ -153,3 +154,50 @@ export function deleteTokens() {
153154
const clusters = Object.keys(store.getState().config.allClusters ?? {});
154155
return Promise.all(clusters.map(cluster => logout(cluster)));
155156
}
157+
158+
/**
159+
* Response from the /clusters/:cluster/me endpoint.
160+
*/
161+
export interface ClusterMeResponse {
162+
username?: string;
163+
email?: string;
164+
groups?: string[];
165+
userInfoURL?: string;
166+
/** Unix timestamp (seconds) when the token expires. Present only when the backend
167+
* can parse an `exp` claim from the token (JWT-based auth). */
168+
tokenExpiry?: number;
169+
}
170+
171+
/**
172+
* Result of a call to fetchClusterMe.
173+
* `tokenExpired` is true when the backend returned 401 (token missing or expired).
174+
* `data` is null when the request failed for any reason other than a 401.
175+
*/
176+
export type ClusterMeResult =
177+
| { tokenExpired: false; data: ClusterMeResponse }
178+
| { tokenExpired: true; data: null }
179+
| { tokenExpired: false; data: null };
180+
181+
/**
182+
* Fetches identity and token-expiry information for the given cluster from the
183+
* Headlamp backend's /clusters/:cluster/me endpoint.
184+
*
185+
* The backend validates the per-cluster cookie and returns 401 when the token
186+
* is missing or has already expired — callers should treat that as a signal to
187+
* log the user out.
188+
*
189+
* @param cluster - Name of the cluster.
190+
* @returns A ClusterMeResult object.
191+
*/
192+
export async function fetchClusterMe(cluster: string): Promise<ClusterMeResult> {
193+
try {
194+
const response = await backendFetch(`/clusters/${cluster}/me`);
195+
const data: ClusterMeResponse = await response.json();
196+
return { tokenExpired: false, data };
197+
} catch (e) {
198+
if (e instanceof ApiError && e.status === 401) {
199+
return { tokenExpired: true, data: null };
200+
}
201+
return { tokenExpired: false, data: null };
202+
}
203+
}

0 commit comments

Comments
 (0)