Skip to content

Commit dcc808f

Browse files
author
Stanislav Bychkov
committed
redirect/refresh on token expiration
simplified: - deduplicate splitGroupList - nav bar subtitle cleanup - remove env CADENCE_WEB_JWT_TOKEN and its usage
1 parent e38b7ef commit dcc808f

File tree

10 files changed

+220
-122
lines changed

10 files changed

+220
-122
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ Set these environment variables if you need to change their defaults
2222
| CADENCE_WEB_HOSTNAME | Host name to serve on | 0.0.0.0 |
2323
| CADENCE_ADMIN_SECURITY_TOKEN | Admin token for accessing admin methods | '' |
2424
| CADENCE_WEB_RBAC_ENABLED | Enables RBAC-aware UI (login/logout). | false |
25-
| CADENCE_WEB_JWT_TOKEN | Static Cadence JWT forwarded when no request token exists | '' |
2625
| CADENCE_GRPC_TLS_CA_FILE | Path to root CA certificate file for enabling one-way TLS on gRPC connections | '' |
2726
| CADENCE_WEB_SERVICE_NAME | Name of the web service used as GRPC caller and OTEL resource name | cadence-web |
2827

src/components/app-nav-bar/app-nav-bar.tsx

Lines changed: 101 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
'use client';
2-
import React, { useMemo, useState } from 'react';
2+
import React, {
3+
useCallback,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react';
39

410
import { AppNavBar as BaseAppNavBar } from 'baseui/app-nav-bar';
511
import { useSnackbar } from 'baseui/snackbar';
612
import NextLink from 'next/link';
7-
import { useRouter } from 'next/navigation';
13+
import { usePathname, useRouter } from 'next/navigation';
814

915
import useStyletronClasses from '@/hooks/use-styletron-classes';
1016
import useUserInfo from '@/hooks/use-user-info/use-user-info';
@@ -20,40 +26,52 @@ const LOGOUT_ITEM = 'logout';
2026
export default function AppNavBar() {
2127
const { cls } = useStyletronClasses(cssStyles);
2228
const router = useRouter();
29+
const pathname = usePathname();
2330
const { enqueue } = useSnackbar();
2431
const [isModalOpen, setIsModalOpen] = useState(false);
2532

2633
const { data: authInfo, isLoading: isAuthLoading, refetch } = useUserInfo();
34+
const isRbacEnabled = authInfo?.rbacEnabled === true;
35+
const isAuthenticated = authInfo?.isAuthenticated === true;
36+
const isAdmin = authInfo?.isAdmin === true;
37+
const expiresAtMs =
38+
typeof authInfo?.expiresAtMs === 'number'
39+
? authInfo.expiresAtMs
40+
: undefined;
41+
const logoutInFlightRef = useRef(false);
42+
const prevIsAuthenticatedRef = useRef<boolean | null>(null);
43+
const logoutReasonRef = useRef<'manual' | 'expired' | null>(null);
44+
2745
const userItems = useMemo(() => {
28-
if (!authInfo?.rbacEnabled) return undefined;
29-
if (!authInfo.isAuthenticated) {
46+
if (!isRbacEnabled) return undefined;
47+
if (!isAuthenticated) {
3048
return [{ label: 'Login with JWT', info: LOGIN_ITEM }];
3149
}
3250
return [
3351
{ label: 'Switch token', info: LOGIN_ITEM },
3452
{ label: 'Logout', info: LOGOUT_ITEM },
3553
];
36-
}, [authInfo]);
54+
}, [isAuthenticated, isRbacEnabled]);
3755

3856
const username = useMemo(() => {
39-
if (!authInfo?.rbacEnabled) {
57+
if (!isRbacEnabled) {
4058
return undefined;
4159
}
4260
if (isAuthLoading || !authInfo) {
4361
return 'Checking access...';
4462
}
45-
return authInfo.isAuthenticated
46-
? authInfo.userName || 'Authenticated user'
63+
return isAuthenticated
64+
? authInfo.userName || 'Authenticated user (unknown username)'
4765
: 'Authenticate';
48-
}, [authInfo, isAuthLoading]);
66+
}, [authInfo, isAuthLoading, isAuthenticated, isRbacEnabled]);
4967

5068
const usernameSubtitle =
51-
authInfo?.rbacEnabled && authInfo.isAuthenticated
52-
? authInfo.isAdmin
69+
isRbacEnabled && isAuthenticated
70+
? isAdmin
5371
? 'Admin'
54-
: 'Authenticated'
55-
: authInfo?.rbacEnabled
56-
? 'Paste a Cadence JWT'
72+
: undefined
73+
: isRbacEnabled
74+
? 'Provide a Cadence JWT'
5775
: undefined;
5876

5977
const saveToken = async (token: string) => {
@@ -75,19 +93,74 @@ export default function AppNavBar() {
7593
}
7694
};
7795

78-
const logout = async () => {
79-
try {
80-
await request('/api/auth/token', { method: 'DELETE' });
81-
enqueue({ message: 'Signed out' });
82-
} catch (e) {
83-
const message = e instanceof Error ? e.message : 'Failed to sign out';
84-
enqueue({ message });
85-
} finally {
86-
setIsModalOpen(false);
87-
await refetch();
88-
router.refresh();
96+
const logout = useCallback(
97+
async (reason: 'manual' | 'expired') => {
98+
if (logoutInFlightRef.current) return;
99+
logoutInFlightRef.current = true;
100+
logoutReasonRef.current = reason;
101+
try {
102+
await request('/api/auth/token', { method: 'DELETE' });
103+
} catch (e) {
104+
logoutReasonRef.current = null;
105+
const message = e instanceof Error ? e.message : 'Failed to sign out';
106+
enqueue({ message });
107+
} finally {
108+
setIsModalOpen(false);
109+
await refetch();
110+
router.refresh();
111+
router.replace('/domains');
112+
logoutInFlightRef.current = false;
113+
}
114+
},
115+
[enqueue, refetch, router]
116+
);
117+
118+
useEffect(() => {
119+
if (!isRbacEnabled || isAuthLoading || !authInfo) return;
120+
const prevIsAuthenticated = prevIsAuthenticatedRef.current;
121+
prevIsAuthenticatedRef.current = isAuthenticated;
122+
123+
if (prevIsAuthenticated === true && !isAuthenticated) {
124+
const reason = logoutReasonRef.current;
125+
logoutReasonRef.current = null;
126+
enqueue({
127+
message:
128+
reason === 'manual'
129+
? 'Signed out'
130+
: 'Session expired. Please sign in again.',
131+
});
132+
if (pathname === '/domains') {
133+
router.refresh();
134+
} else {
135+
router.replace('/domains');
136+
}
89137
}
90-
};
138+
}, [
139+
authInfo,
140+
enqueue,
141+
isAuthenticated,
142+
isAuthLoading,
143+
isRbacEnabled,
144+
pathname,
145+
router,
146+
]);
147+
148+
useEffect(() => {
149+
if (!isRbacEnabled || !isAuthenticated || expiresAtMs === undefined) return;
150+
const timeoutMs = expiresAtMs - Date.now();
151+
if (timeoutMs <= 0) {
152+
void logout('expired');
153+
return;
154+
}
155+
156+
const id = window.setTimeout(() => {
157+
void logout('expired');
158+
}, timeoutMs);
159+
160+
return () => {
161+
window.clearTimeout(id);
162+
};
163+
}, [expiresAtMs, isAuthenticated, isRbacEnabled, logout]);
91164

92165
return (
93166
<>
@@ -113,11 +186,11 @@ export default function AppNavBar() {
113186
if (item.info === LOGIN_ITEM) {
114187
setIsModalOpen(true);
115188
} else if (item.info === LOGOUT_ITEM) {
116-
void logout();
189+
void logout('manual');
117190
}
118191
}}
119192
/>
120-
{authInfo?.rbacEnabled && (
193+
{isRbacEnabled && (
121194
<AuthTokenModal
122195
isOpen={isModalOpen}
123196
onClose={() => setIsModalOpen(false)}

src/components/snackbar-provider/snackbar-provider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function SnackbarProvider({ children }: Props) {
1313
<BaseSnackbarProvider
1414
placement={PLACEMENT.bottom}
1515
overrides={overrides.snackbar}
16-
defaultDuration={DURATION.infinite}
16+
defaultDuration={DURATION.medium}
1717
>
1818
{children}
1919
</BaseSnackbarProvider>

src/config/dynamic/dynamic.config.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ const dynamicConfigs: {
2727
CADENCE_WEB_PORT: ConfigEnvDefinition;
2828
ADMIN_SECURITY_TOKEN: ConfigEnvDefinition;
2929
CADENCE_WEB_RBAC_ENABLED: ConfigEnvDefinition;
30-
CADENCE_WEB_JWT_TOKEN: ConfigEnvDefinition;
3130
CLUSTERS: ConfigSyncResolverDefinition<
3231
undefined,
3332
ClustersConfigs,
@@ -95,10 +94,6 @@ const dynamicConfigs: {
9594
env: 'CADENCE_WEB_RBAC_ENABLED',
9695
default: 'false',
9796
},
98-
CADENCE_WEB_JWT_TOKEN: {
99-
env: 'CADENCE_WEB_JWT_TOKEN',
100-
default: process.env.CADENCE_WEB_RBAC_TOKEN || '',
101-
},
10297
CLUSTERS: {
10398
resolver: clusters,
10499
evaluateOn: 'serverStart',

src/utils/auth/__tests__/auth-context.test.ts

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ describe('auth-context utilities', () => {
5252
});
5353
mockGetConfigValue.mockImplementation(async (key: string) => {
5454
if (key === 'CADENCE_WEB_RBAC_ENABLED') return 'true';
55-
if (key === 'CADENCE_WEB_JWT_TOKEN') return 'env-token';
5655
return '';
5756
});
5857

@@ -65,20 +64,14 @@ describe('auth-context utilities', () => {
6564
rbacEnabled: true,
6665
isAdmin: true,
6766
token,
68-
tokenSource: 'cookie',
6967
groups: ['worker'],
7068
userName: 'cookie-user',
7169
});
7270
});
7371

74-
it('falls back to env token when cookie is missing', async () => {
75-
const envToken = buildToken({
76-
sub: 'env-user',
77-
groups: ['reader'],
78-
});
72+
it('returns unauthenticated context when cookie is missing', async () => {
7973
mockGetConfigValue.mockImplementation(async (key: string) => {
8074
if (key === 'CADENCE_WEB_RBAC_ENABLED') return 'true';
81-
if (key === 'CADENCE_WEB_JWT_TOKEN') return envToken;
8275
return '';
8376
});
8477

@@ -88,12 +81,64 @@ describe('auth-context utilities', () => {
8881

8982
expect(authContext).toMatchObject({
9083
rbacEnabled: true,
91-
token: envToken,
92-
tokenSource: 'env',
93-
groups: ['reader'],
84+
token: undefined,
85+
});
86+
});
87+
88+
it('treats expired tokens as unauthenticated', async () => {
89+
const nowMs = 1_700_000_000_000;
90+
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(nowMs);
91+
92+
const token = buildToken({
93+
sub: 'expired-user',
94+
groups: ['worker'],
95+
Admin: true,
96+
exp: Math.floor(nowMs / 1000) - 10,
97+
});
98+
mockGetConfigValue.mockImplementation(async (key: string) => {
99+
if (key === 'CADENCE_WEB_RBAC_ENABLED') return 'true';
100+
return '';
101+
});
102+
103+
const authContext = await resolveAuthContext({
104+
get: (name: string) =>
105+
name === CADENCE_AUTH_COOKIE_NAME ? { value: token } : undefined,
106+
});
107+
108+
expect(authContext).toMatchObject({
109+
rbacEnabled: true,
110+
token: undefined,
94111
isAdmin: false,
95-
userName: 'env-user',
112+
groups: [],
113+
userName: undefined,
114+
expiresAtMs: undefined,
115+
});
116+
117+
dateNowSpy.mockRestore();
118+
});
119+
120+
it('exposes expiresAtMs for valid tokens', async () => {
121+
const nowMs = 1_700_000_000_000;
122+
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(nowMs);
123+
const expSeconds = Math.floor(nowMs / 1000) + 60;
124+
125+
const token = buildToken({
126+
sub: 'exp-user',
127+
exp: expSeconds,
96128
});
129+
mockGetConfigValue.mockImplementation(async (key: string) => {
130+
if (key === 'CADENCE_WEB_RBAC_ENABLED') return 'true';
131+
return '';
132+
});
133+
134+
const authContext = await resolveAuthContext({
135+
get: (name: string) =>
136+
name === CADENCE_AUTH_COOKIE_NAME ? { value: token } : undefined,
137+
});
138+
139+
expect(authContext.expiresAtMs).toBe(expSeconds * 1000);
140+
141+
dateNowSpy.mockRestore();
97142
});
98143

99144
it('handles capitalized Groups claim with comma-separated string', async () => {
@@ -104,12 +149,12 @@ describe('auth-context utilities', () => {
104149
});
105150
mockGetConfigValue.mockImplementation(async (key: string) => {
106151
if (key === 'CADENCE_WEB_RBAC_ENABLED') return 'true';
107-
if (key === 'CADENCE_WEB_JWT_TOKEN') return token;
108152
return '';
109153
});
110154

111155
const authContext = await resolveAuthContext({
112-
get: () => undefined,
156+
get: (name: string) =>
157+
name === CADENCE_AUTH_COOKIE_NAME ? { value: token } : undefined,
113158
});
114159

115160
expect(authContext.groups).toEqual(['readers', 'auditors']);
@@ -124,31 +169,31 @@ describe('auth-context utilities', () => {
124169
});
125170
mockGetConfigValue.mockImplementation(async (key: string) => {
126171
if (key === 'CADENCE_WEB_RBAC_ENABLED') return 'true';
127-
if (key === 'CADENCE_WEB_JWT_TOKEN') return token;
128172
return '';
129173
});
130174

131175
const authContext = await resolveAuthContext({
132-
get: () => undefined,
176+
get: (name: string) =>
177+
name === CADENCE_AUTH_COOKIE_NAME ? { value: token } : undefined,
133178
});
134179

135180
expect(authContext.groups).toEqual(['readers', 'auditors']);
136181
expect(authContext.isAdmin).toBe(false);
137182
});
138183

139-
it('still forwards env token when RBAC is disabled', async () => {
184+
it('still forwards cookie token when RBAC is disabled', async () => {
140185
const token = buildToken({
141186
sub: 'legacy-admin',
142187
Admin: true,
143188
});
144189
mockGetConfigValue.mockImplementation(async (key: string) => {
145190
if (key === 'CADENCE_WEB_RBAC_ENABLED') return 'false';
146-
if (key === 'CADENCE_WEB_JWT_TOKEN') return token;
147191
return '';
148192
});
149193

150194
const authContext = await resolveAuthContext({
151-
get: () => undefined,
195+
get: (name: string) =>
196+
name === CADENCE_AUTH_COOKIE_NAME ? { value: token } : undefined,
152197
});
153198

154199
expect(authContext.token).toBe(token);
@@ -398,12 +443,10 @@ describe('auth-context utilities', () => {
398443
});
399444

400445
describe(getPublicAuthContext.name, () => {
401-
it('omits the token but preserves flags', () => {
446+
it('omits private fields but preserves flags', () => {
402447
const authContext = {
403448
rbacEnabled: true,
404449
token: 'secret',
405-
tokenSource: 'cookie' as const,
406-
claims: { Admin: true },
407450
groups: ['worker'],
408451
isAdmin: true,
409452
userName: 'worker',
@@ -412,8 +455,6 @@ describe('auth-context utilities', () => {
412455

413456
expect(getPublicAuthContext(authContext)).toEqual({
414457
rbacEnabled: true,
415-
tokenSource: 'cookie',
416-
claims: { Admin: true },
417458
groups: ['worker'],
418459
isAdmin: true,
419460
userName: 'worker',

0 commit comments

Comments
 (0)