Skip to content
Open
29 changes: 22 additions & 7 deletions backend/pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,8 @@ func HandleMe(opts MeHandlerOptions) http.HandlerFunc {
return
}

if expiry, err := GetExpiryUnixTimeUTC(claims); err != nil || time.Now().After(expiry) {
expiry, err := GetExpiryUnixTimeUTC(claims)
if err != nil || time.Now().After(expiry) {
writeMeJSON(w, http.StatusUnauthorized, map[string]interface{}{"message": "token expired"})
return
}
Expand All @@ -379,7 +380,7 @@ func HandleMe(opts MeHandlerOptions) http.HandlerFunc {
email := stringValueFromJMESPaths(claims, compiledEmailPaths)
groups := stringSliceFromJMESPaths(claims, compiledGroupsPaths)

writeMeResponse(w, username, email, groups, userInfoURL)
writeMeResponse(w, username, email, groups, userInfoURL, expiry)
Comment thread
prabindersinghh marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -425,19 +426,33 @@ func tryProxyAuth(w http.ResponseWriter, r *http.Request, opts MeHandlerOptions,
}
}

writeMeResponse(w, username, email, groups, userInfoURL)
writeMeResponse(w, username, email, groups, userInfoURL, time.Time{})

return true
}

// writeMeResponse writes the successful response for HandleMe with the standard cache-busting headers.
func writeMeResponse(w http.ResponseWriter, username, email string, groups []string, userInfoURL string) {
writeMeJSON(w, http.StatusOK, map[string]interface{}{
// writeMeResponse serializes the identity payload with the standard cache-busting headers.
// tokenExpiry is the token's expiry time; if non-zero it is included so the frontend
// can warn users before their session ends.
func writeMeResponse(
w http.ResponseWriter,
username, email string,
groups []string,
userInfoURL string,
tokenExpiry time.Time,
) {
payload := map[string]interface{}{
"username": username,
"email": email,
"groups": groups,
"userInfoURL": userInfoURL,
})
}

if !tokenExpiry.IsZero() {
payload["tokenExpiry"] = tokenExpiry.Unix()
}

writeMeJSON(w, http.StatusOK, payload)
}

// writeMeJSON sets the standard cache-control headers used by /me responses and writes the JSON payload.
Expand Down
35 changes: 35 additions & 0 deletions backend/pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1285,3 +1285,38 @@ func TestHandleMe_MissingCookie(t *testing.T) {
assert.Equal(t, "no-store, no-cache, must-revalidate, private", rr.Header().Get("Cache-Control"))
assert.Equal(t, "Cookie", rr.Header().Get("Vary"))
}

func TestHandleMe_IncludesTokenExpiry(t *testing.T) {
t.Parallel()

futureExpiry := time.Now().Add(time.Hour).Unix()
claims := map[string]interface{}{
"preferred_username": "alice",
"exp": float64(futureExpiry),
}

token := makeTestToken(t, claims)

req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/clusters/test/me", nil)
req = mux.SetURLVars(req, map[string]string{"clusterName": "test"})
req.Header.Set("Authorization", "Bearer "+token)

rr := httptest.NewRecorder()

handler := auth.HandleMe(auth.MeHandlerOptions{
UsernamePaths: "preferred_username",
})

handler(rr, req)

require.Equal(t, http.StatusOK, rr.Code)

var got struct {
Username string `json:"username"`
TokenExpiry int64 `json:"tokenExpiry"`
}

require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
assert.Equal(t, "alice", got.Username)
assert.Equal(t, futureExpiry, got.TokenExpiry, "tokenExpiry should match the JWT exp claim as a Unix timestamp")
}
2 changes: 2 additions & 0 deletions frontend/src/components/App/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import Sidebar, { NavigationTabs } from '../Sidebar';
import RouteSwitcher from './RouteSwitcher';
import ShortcutsSettings from './Settings/ShortcutsSettings';
import { applyBackendThemeConfig } from './themeSlice';
import TokenExpiryNotification from './TokenExpiryNotification';
import TopBar from './TopBar';
import VersionDialog from './VersionDialog';

Expand Down Expand Up @@ -346,6 +347,7 @@ export default function Layout({}: LayoutProps) {
<ClusterNotFoundPopup key={clusterName} cluster={clusterName} />
))}
<AlertNotification />
<TokenExpiryNotification />
Comment thread
prabindersinghh marked this conversation as resolved.
<Box sx={{ height: '100%' }}>
<Div />
<Container {...containerProps} sx={{ height: '100%' }}>
Expand Down
123 changes: 123 additions & 0 deletions frontend/src/components/App/TokenExpiryNotification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ClusterMeResult } from '../../lib/auth';
import { TestContext } from '../../test';
import { PureTokenExpiryNotification } from './TokenExpiryNotification';

Comment thread
prabindersinghh marked this conversation as resolved.
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key.split('|')[1] || key,
}),
}));

const { mockLogout, mockGetCluster } = vi.hoisted(() => ({
mockLogout: vi.fn().mockResolvedValue(undefined),
mockGetCluster: vi.fn().mockReturnValue('test-cluster'),
}));

vi.mock('../../lib/auth', async () => ({
...(await vi.importActual<typeof import('../../lib/auth')>('../../lib/auth')),
logout: (...args: any[]) => mockLogout(...args),
}));

vi.mock('../../lib/cluster', () => ({
getCluster: () => mockGetCluster(),
}));

vi.mock('../../lib/router/getRoute', () => ({
getRoute: (name: string) => {
const routes: Record<string, { path: string }> = {
login: { path: '/login' },
token: { path: '/token' },
settingsCluster: { path: '/settings' },
};
return routes[name] ?? null;
},
}));
Comment thread
prabindersinghh marked this conversation as resolved.

vi.mock('../../lib/router/getRoutePath', () => ({
getRoutePath: (route: { path: string }) => route.path,
}));

function makeFetch(result: ClusterMeResult) {
return vi.fn().mockResolvedValue(result);
}

describe('PureTokenExpiryNotification', () => {
beforeEach(() => {
mockLogout.mockReset().mockResolvedValue(undefined);
mockGetCluster.mockReturnValue('test-cluster');
});

it('shows warning banner when token expiry is within 2 minutes', async () => {
const expiry = Math.floor(Date.now() / 1000) + 90;
render(
<TestContext>
<PureTokenExpiryNotification
fetchClusterMeFn={makeFetch({ tokenExpired: false, data: { tokenExpiry: expiry } })}
/>
</TestContext>
);

await waitFor(() => {
expect(screen.getByText(/Session expires in/)).toBeInTheDocument();
});
});

it('shows no warning when token expiry is more than 2 minutes away', async () => {
const expiry = Math.floor(Date.now() / 1000) + 3600;
const fetchFn = makeFetch({ tokenExpired: false, data: { tokenExpiry: expiry } });
render(
<TestContext>
<PureTokenExpiryNotification fetchClusterMeFn={fetchFn} />
</TestContext>
);

await waitFor(() => expect(fetchFn).toHaveBeenCalled());
expect(screen.queryByText(/Session expires in/)).not.toBeInTheDocument();
});

it('calls logout when the backend reports the token as expired', async () => {
render(
<TestContext>
<PureTokenExpiryNotification
fetchClusterMeFn={makeFetch({ tokenExpired: true, data: null })}
/>
</TestContext>
);

await waitFor(() => {
expect(mockLogout).toHaveBeenCalledWith('test-cluster');
});
});

it('suppresses the banner on excluded routes', async () => {
const expiry = Math.floor(Date.now() / 1000) + 90;
const fetchFn = makeFetch({ tokenExpired: false, data: { tokenExpiry: expiry } });
render(
<TestContext urlPrefix="/login">
<PureTokenExpiryNotification fetchClusterMeFn={fetchFn} />
</TestContext>
);

await waitFor(() => expect(fetchFn).toHaveBeenCalled());
expect(screen.queryByText(/Session expires in/)).not.toBeInTheDocument();
expect(screen.queryByText(/Session expired/)).not.toBeInTheDocument();
});
});
Loading
Loading