diff --git a/README.md b/README.md index 6bd68b4..324ed6d 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,20 @@ VITE_KEYCLOAK_CLIENT_ID=opsapp --- +## Security Notes + +**Why ROPC (Resource Owner Password Credentials)?** This app owns its own login form and is served from the same trusted origin as the app itself — it is a first-party client, not a third party integrating with Keycloak. Redirect-based OIDC flows exist specifically to avoid a client ever seeing the user's raw credentials; that concern doesn't apply here since the login form *is* the client. ROPC is an accepted grant type for exactly this kind of first-party, trusted-client scenario. + +**Token storage.** Access, refresh, and ID tokens are stored in `localStorage` (`kc_token`, `kc_refresh_token`, `kc_id_token`) — a deliberate choice, not an oversight. The alternative (in-memory only) would log the user out on every page refresh, since this app has no cookie-based or silent-SSO recovery mechanism to re-establish a session after a reload. The accepted tradeoff is XSS exposure: if malicious script ever runs in this app's origin, it can read these tokens. Mitigate by keeping dependencies patched and avoiding `dangerouslySetInnerHTML`/unsanitized third-party content; this is a separate, ongoing hardening concern from the auth flow itself. + +**No tokens in URLs.** ROPC posts credentials and receives tokens in request/response bodies only. `keycloak.logout()`'s `redirectUri` is a return-to address, not a token carrier. No query string or URL fragment ever carries a token in this app. + +**`Platform-TenantId` and `Authorization` headers** are injected on every request from both axios instances (`src/lib/api/client.ts` and `src/lib/api/g2pConfig.ts`) via the shared `createAuthInterceptors()` factory in `src/lib/api/authInterceptors.ts`. + +**Refresh tokens are never logged.** Every place that reads a token value (`src/lib/keycloak/refresh.ts`, `src/lib/api/authInterceptors.ts`, `src/lib/keycloak/KeycloakProvider.tsx`) only ever logs or surfaces booleans (refreshed / not), HTTP status codes, or `.message` strings — never the token itself. Keep it that way: **never `console.log` a token, refresh token, or ID token value**, even for debugging; log success/failure booleans instead. + +--- + ## Gazelle Backend Setup Add to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on Windows, `/etc/hosts` on Linux/macOS): diff --git a/src/components/shared/ForbiddenListener.tsx b/src/components/shared/ForbiddenListener.tsx new file mode 100644 index 0000000..fe192b2 --- /dev/null +++ b/src/components/shared/ForbiddenListener.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { on } from '@/lib/events' +import { useToast } from '@/components/shared/ToastProvider' + +// Axios interceptors have no React/Router context, so a 403 response emits +// a 'forbidden' event instead of navigating directly — this component is +// the one place inside the tree that reacts to it. +export default function ForbiddenListener() { + const navigate = useNavigate() + const { toast } = useToast() + + useEffect(() => { + return on('forbidden', () => { + toast("You don't have permission to access this.", 'error') + navigate('/') + }) + }, [navigate, toast]) + + return null +} diff --git a/src/components/shared/ProtectedRoute.tsx b/src/components/shared/ProtectedRoute.tsx new file mode 100644 index 0000000..f99f145 --- /dev/null +++ b/src/components/shared/ProtectedRoute.tsx @@ -0,0 +1,19 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import type { ReactNode } from 'react' +import { useKeycloak } from '@/lib/keycloak/KeycloakProvider' +import { isTokenExpiringSoon } from '@/lib/keycloak/refresh' + +// Per-navigation auth guard. KeycloakProvider already blocks rendering +// until the initial auth check resolves and redirects on init failure — +// this is the durable guard that re-checks on every route change, closing +// the gap between an in-background token expiry and the next interval tick. +export default function ProtectedRoute({ children }: { children?: ReactNode }) { + const { authenticated } = useKeycloak() + const location = useLocation() + + if (!authenticated || isTokenExpiringSoon(0)) { + return + } + + return children ?? +} diff --git a/src/components/shared/SessionTimeoutModal.tsx b/src/components/shared/SessionTimeoutModal.tsx new file mode 100644 index 0000000..6c6eeaa --- /dev/null +++ b/src/components/shared/SessionTimeoutModal.tsx @@ -0,0 +1,48 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +interface SessionTimeoutModalProps { + open: boolean + onStayLoggedIn: () => void + onLogoutNow: () => void +} + +export default function SessionTimeoutModal({ + open, + onStayLoggedIn, + onLogoutNow, +}: SessionTimeoutModalProps) { + return ( + { if (!next) onLogoutNow() }}> + + + Session about to expire + + +
+ You have been inactive for a while. For your security, you will be + signed out in 2 minutes unless you choose to stay. +
+ + + + + +
+
+ ) +} diff --git a/src/lib/api/authInterceptors.test.ts b/src/lib/api/authInterceptors.test.ts new file mode 100644 index 0000000..4b607d4 --- /dev/null +++ b/src/lib/api/authInterceptors.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeAll, afterEach, afterAll } from 'vitest' +import axios from 'axios' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +vi.mock('@/lib/keycloak/refresh', () => ({ + refreshToken: vi.fn(), +})) +vi.mock('@/lib/events', () => ({ + emit: vi.fn(), +})) + +const { createAuthInterceptors, redirectToLogin } = await import('./authInterceptors') +const { refreshToken } = await import('@/lib/keycloak/refresh') +const { emit } = await import('@/lib/events') + +const BASE_URL = 'https://api.test' + +describe('createAuthInterceptors', () => { + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() + }) + afterAll(() => server.close()) + + it('on 401, retries the original request once after a successful refresh', async () => { + let callCount = 0 + server.use( + http.get(`${BASE_URL}/data`, () => { + callCount += 1 + if (callCount === 1) { + return new HttpResponse(null, { status: 401 }) + } + return HttpResponse.json({ ok: true }) + }) + ) + vi.mocked(refreshToken).mockResolvedValue(true) + + const client = axios.create({ baseURL: BASE_URL }) + createAuthInterceptors(client) + + const response = await client.get('/data') + + expect(response.data).toEqual({ ok: true }) + expect(callCount).toBe(2) + expect(refreshToken).toHaveBeenCalledTimes(1) + }) + + it('on 401 with a failed refresh, does not retry and redirects to login', async () => { + let callCount = 0 + server.use( + http.get(`${BASE_URL}/data`, () => { + callCount += 1 + return new HttpResponse(null, { status: 401 }) + }) + ) + vi.mocked(refreshToken).mockResolvedValue(false) + + // jsdom doesn't implement real navigation — stub the setter so the + // interceptor's window.location.href assignment doesn't log a warning. + const originalHref = window.location.href + const hrefSetter = vi.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, set href(value: string) { hrefSetter(value) } }, + writable: true, + }) + + const client = axios.create({ baseURL: BASE_URL }) + createAuthInterceptors(client) + + await expect(client.get('/data')).rejects.toBeTruthy() + expect(callCount).toBe(1) + expect(sessionStorage.getItem('logout_reason')).toBe('expired') + expect(hrefSetter).toHaveBeenCalledWith('/login') + + sessionStorage.removeItem('logout_reason') + Object.defineProperty(window, 'location', { value: { ...window.location, href: originalHref }, writable: true }) + }) + + it('on 403, emits a forbidden event exactly once with no retry', async () => { + let callCount = 0 + server.use( + http.get(`${BASE_URL}/data`, () => { + callCount += 1 + return new HttpResponse(null, { status: 403 }) + }) + ) + + const client = axios.create({ baseURL: BASE_URL }) + createAuthInterceptors(client) + + await expect(client.get('/data')).rejects.toBeTruthy() + expect(callCount).toBe(1) + expect(emit).toHaveBeenCalledWith('forbidden') + expect(vi.mocked(emit).mock.calls.filter(([type]) => type === 'forbidden')).toHaveLength(1) + }) +}) + +describe('redirectToLogin', () => { + it('is exported as a standalone function for testability', () => { + expect(typeof redirectToLogin).toBe('function') + }) +}) diff --git a/src/lib/api/authInterceptors.ts b/src/lib/api/authInterceptors.ts new file mode 100644 index 0000000..f4d62a8 --- /dev/null +++ b/src/lib/api/authInterceptors.ts @@ -0,0 +1,67 @@ +import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios' +import keycloak, { TOKEN_KEY, clearTokens } from '@/lib/keycloak/keycloak' +import { refreshToken } from '@/lib/keycloak/refresh' +import { emit } from '@/lib/events' + +interface RetryableConfig extends InternalAxiosRequestConfig { + _retry?: boolean +} + +// Hard redirect, not React Router's navigate() — this runs inside an axios +// interceptor, outside the React tree / Router context. Exported so tests +// can spy on it instead of asserting against a real browser navigation. +export function redirectToLogin(reason: 'expired') { + sessionStorage.setItem('logout_reason', reason) + window.location.href = '/login' +} + +// Attaches the Platform-TenantId + Authorization headers, activity +// tracking, and 401 (refresh-then-retry) / 403 (friendly redirect) handling +// shared by every axios instance in the app. Never logs token values. +export function createAuthInterceptors(client: AxiosInstance) { + client.interceptors.request.use((config) => { + const tenant = localStorage.getItem('tenant') || 'greenbank' + config.headers.set('Platform-TenantId', tenant) + + const token = keycloak.token ?? localStorage.getItem(TOKEN_KEY) + if (token) config.headers.set('Authorization', `Bearer ${token}`) + + emit('activity') + + return config + }) + + client.interceptors.response.use( + (response) => response, + async (error) => { + const status = error.response?.status + const config = error.config as RetryableConfig | undefined + + if (status === 401 && config && !config._retry) { + config._retry = true + const refreshed = await refreshToken() + + if (refreshed) { + const freshToken = keycloak.token ?? localStorage.getItem(TOKEN_KEY) + if (freshToken) config.headers.set('Authorization', `Bearer ${freshToken}`) + return client(config) + } + + // Refresh itself failed — clear tokens and hand off to the login + // redirect. KeycloakProvider's own interval/onTokenExpired paths + // handle the "still logged in but token died" case; this covers + // the "an API call surfaced it first" case. + clearTokens() + redirectToLogin('expired') + return Promise.reject(error) + } + + if (status === 403) { + emit('forbidden') + return Promise.reject(error) + } + + return Promise.reject(error) + } + ) +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 6fbd51b..77903c7 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,25 +1,10 @@ import axios from 'axios' +import { createAuthInterceptors } from './authInterceptors' const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, }) -apiClient.interceptors.request.use((config) => { - const tenant = localStorage.getItem('tenant') || 'greenbank' - config.headers.set('Platform-TenantId', tenant) - - return config -}) - -apiClient.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('kc_token') - window.location.href = '/login' - } - return Promise.reject(error) - } -) +createAuthInterceptors(apiClient) export default apiClient diff --git a/src/lib/api/g2pConfig.ts b/src/lib/api/g2pConfig.ts index 53c094a..23b0fd7 100644 --- a/src/lib/api/g2pConfig.ts +++ b/src/lib/api/g2pConfig.ts @@ -1,6 +1,8 @@ import axios from 'axios' +import { createAuthInterceptors } from './authInterceptors' const g2pClient = axios.create({ baseURL: import.meta.env.VITE_G2P_SERVICE_URL || 'http://localhost:8084' }) +createAuthInterceptors(g2pClient) export const fetchG2PConfigs = async () => { const response = await g2pClient.get('/g2pPaymentConfig') diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..80e7c53 --- /dev/null +++ b/src/lib/events.ts @@ -0,0 +1,15 @@ +// Tiny typed event bus bridging non-React modules (axios interceptors) +// back into the React tree — e.g. a 403 response has no React context to +// navigate/toast from, so it emits an event a component can subscribe to. +export type AppEventType = 'forbidden' | 'activity' + +const target = new EventTarget() + +export function emit(type: AppEventType) { + target.dispatchEvent(new Event(type)) +} + +export function on(type: AppEventType, handler: () => void): () => void { + target.addEventListener(type, handler) + return () => target.removeEventListener(type, handler) +} diff --git a/src/lib/keycloak/KeycloakProvider.tsx b/src/lib/keycloak/KeycloakProvider.tsx index 13f11cd..e182127 100644 --- a/src/lib/keycloak/KeycloakProvider.tsx +++ b/src/lib/keycloak/KeycloakProvider.tsx @@ -1,16 +1,31 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' -import keycloak, { decodeJwtPayload } from './keycloak' +import keycloak, { + decodeJwtPayload, + persistTokens, + clearTokens, + TOKEN_KEY, + REFRESH_TOKEN_KEY, + ID_TOKEN_KEY, +} from './keycloak' +import { refreshToken as silentRefresh } from './refresh' +import { useIdleTimer } from './useIdleTimer' import { useToast } from '@/components/shared/ToastProvider' +import SessionTimeoutModal from '@/components/shared/SessionTimeoutModal' -const EXPIRY_CHECK_INTERVAL_MS = 60_000 -const EXPIRY_WARNING_THRESHOLD_MS = 120_000 +const REFRESH_CHECK_INTERVAL_MS = 60_000 +const REFRESH_IF_UNDER_MS = 5 * 60_000 // proactively refresh once < 5 min remain + +const INACTIVITY_IDLE_MS = 15 * 60_000 // 15 min of no activity +const INACTIVITY_WARN_BEFORE_MS = 2 * 60_000 // warn 2 min before that + +type LogoutReason = 'inactivity' | 'expired' | 'manual' interface KeycloakContextValue { keycloak: typeof keycloak authenticated: boolean token: string | undefined - logout: () => void + logout: (reason?: LogoutReason) => void } const KeycloakContext = createContext(null) @@ -26,33 +41,85 @@ export default function KeycloakProvider({ children }: { children: ReactNode }) const { toast } = useToast() const [initialized, setInitialized] = useState(false) const [authenticated, setAuthenticated] = useState(false) - const warnedForTokenRef = useRef(null) + const [showTimeoutModal, setShowTimeoutModal] = useState(false) + const refreshInFlightRef = useRef(false) + + function logout(reason: LogoutReason = 'manual') { + clearTokens() + if (reason !== 'manual') { + sessionStorage.setItem('logout_reason', reason) + } + // keycloak-js was hydrated with the real refresh/id token at login time, + // so this performs genuine RP-initiated logout against Keycloak's + // end-session endpoint (server-side revocation), not just a local clear. + keycloak.logout({ redirectUri: `${window.location.origin}/login` }) + } + + const idleTimer = useIdleTimer({ + idleMs: INACTIVITY_IDLE_MS, + warnBeforeMs: INACTIVITY_WARN_BEFORE_MS, + onWarn: () => setShowTimeoutModal(true), + onIdle: () => logout('inactivity'), + }) + + async function handleStayLoggedIn() { + setShowTimeoutModal(false) + await silentRefresh() + idleTimer.reset() + } useEffect(() => { - // Fast path: ROPC token already in localStorage — validate expiry and skip keycloak.init() - const existingToken = localStorage.getItem('kc_token') + // Already initialized by Login.tsx's ROPC flow in this tab session — + // keycloak-js throws if init() is called a second time, so just read + // its current state instead of re-initializing. + if (keycloak.didInitialize) { + setAuthenticated(keycloak.authenticated ?? false) + if (keycloak.token) persistTokens(keycloak) + setInitialized(true) + return + } + + // Reload case: keycloak-js is a fresh instance with no in-memory state, + // but a previous ROPC login may have left tokens in localStorage — + // re-hydrate the singleton from them so updateToken()/logout() keep + // working after a refresh. + const existingToken = localStorage.getItem(TOKEN_KEY) + const existingRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) + const existingIdToken = localStorage.getItem(ID_TOKEN_KEY) + if (existingToken) { try { const payload = decodeJwtPayload(existingToken) if (payload.exp * 1000 > Date.now()) { - setAuthenticated(true) - setInitialized(true) + keycloak + .init({ + token: existingToken, + refreshToken: existingRefreshToken ?? undefined, + idToken: existingIdToken ?? undefined, + }) + .then((auth) => { + setAuthenticated(auth) + if (auth) persistTokens(keycloak) + else clearTokens() + setInitialized(true) + }) + .catch(() => { + clearTokens() + setAuthenticated(false) + setInitialized(true) + }) return } else { - localStorage.removeItem('kc_token') + clearTokens() } } catch { - localStorage.removeItem('kc_token') + clearTokens() } } - if (keycloak.didInitialize) { - setAuthenticated(keycloak.authenticated ?? false) - if (keycloak.token) localStorage.setItem('kc_token', keycloak.token) - setInitialized(true) - return - } - + // No usable ROPC session — fall back to the redirect-based OIDC flow + // (kept as a defensive path; harmless even though ROPC is the only + // login entry point in this app today). keycloak .init({ onLoad: 'check-sso', @@ -60,71 +127,71 @@ export default function KeycloakProvider({ children }: { children: ReactNode }) pkceMethod: 'S256', }) .then((auth) => { - console.log('Auth status:', auth) setAuthenticated(auth) - if (auth && keycloak.token) { - localStorage.setItem('kc_token', keycloak.token) + if (auth) { + persistTokens(keycloak) } else { - localStorage.removeItem('kc_token') + clearTokens() navigate('/login', { replace: true }) } setInitialized(true) }) - .catch((err) => { - console.error('Keycloak init error:', err) + .catch(() => { setInitialized(true) navigate('/login', { replace: true }) }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Set once initialization has resolved either way, so it always refers + // to a live keycloak instance with onTokenExpired wiring in place. + useEffect(() => { + if (!initialized) return keycloak.onTokenExpired = () => { - keycloak.updateToken(60).then((refreshed) => { - if (refreshed && keycloak.token) { - localStorage.setItem('kc_token', keycloak.token) + silentRefresh().then((ok) => { + if (!ok) { + setAuthenticated(false) + navigate('/login', { replace: true }) } - }).catch(() => { - localStorage.removeItem('kc_token') - keycloak.logout() }) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [initialized, navigate]) - // Poll token expiry — auto-logout when expired, warn shortly before + // Proactive refresh loop: while a session is active, check the token's + // remaining validity and refresh it well before it actually expires. + // onTokenExpired above remains a defense-in-depth backstop for cases + // where a tick is missed entirely (e.g. laptop sleep). useEffect(() => { - const interval = setInterval(() => { - const token = localStorage.getItem('kc_token') - if (!token) return + const interval = setInterval(async () => { + const token = localStorage.getItem(TOKEN_KEY) + if (!token || refreshInFlightRef.current) return + let msUntilExpiry: number try { - const payload = decodeJwtPayload(token) - const msUntilExpiry = payload.exp * 1000 - Date.now() + msUntilExpiry = decodeJwtPayload(token).exp * 1000 - Date.now() + } catch { + setAuthenticated(false) + navigate('/login', { replace: true }) + return + } - if (msUntilExpiry < 0) { - localStorage.removeItem('kc_token') - setAuthenticated(false) - navigate('/login', { replace: true }) - return - } + if (msUntilExpiry >= REFRESH_IF_UNDER_MS) return - if (msUntilExpiry < EXPIRY_WARNING_THRESHOLD_MS && warnedForTokenRef.current !== token) { - warnedForTokenRef.current = token - toast('Your session will expire in 2 minutes', 'warning') - } - } catch { - localStorage.removeItem('kc_token') + refreshInFlightRef.current = true + const ok = await silentRefresh() + refreshInFlightRef.current = false + + if (!ok) { setAuthenticated(false) + toast('Your session has expired. Please log in again.', 'error') navigate('/login', { replace: true }) } - }, EXPIRY_CHECK_INTERVAL_MS) + }, REFRESH_CHECK_INTERVAL_MS) return () => clearInterval(interval) }, [navigate, toast]) - function logout() { - localStorage.removeItem('kc_token') - keycloak.logout({ redirectUri: `${window.location.origin}/login` }) - } - if (!initialized) { return (
@@ -139,6 +206,11 @@ export default function KeycloakProvider({ children }: { children: ReactNode }) return ( {children} + { setShowTimeoutModal(false); logout('inactivity') }} + /> ) } diff --git a/src/lib/keycloak/keycloak.ts b/src/lib/keycloak/keycloak.ts index 1a3455a..ec842ed 100644 --- a/src/lib/keycloak/keycloak.ts +++ b/src/lib/keycloak/keycloak.ts @@ -1,5 +1,8 @@ import Keycloak from 'keycloak-js' +// Single shared Keycloak instance for the whole app — Login.tsx (ROPC), +// KeycloakProvider.tsx, and the axios interceptors all read/write this same +// object. Nothing else should construct a second `new Keycloak(...)`. const keycloak = new Keycloak({ url: import.meta.env.VITE_KEYCLOAK_URL, realm: import.meta.env.VITE_KEYCLOAK_REALM, @@ -14,4 +17,24 @@ export function decodeJwtPayload(token: string) { return JSON.parse(atob(padded)) } +export const TOKEN_KEY = 'kc_token' +export const REFRESH_TOKEN_KEY = 'kc_refresh_token' +export const ID_TOKEN_KEY = 'kc_id_token' + +// Single source of truth for the token-storage contract — every read/write +// of the kc_* localStorage keys should go through these two functions +// instead of raw localStorage calls, so the set of persisted keys can +// change in one place. Never log the values passed through here. +export function persistTokens(kc: Pick) { + if (kc.token) localStorage.setItem(TOKEN_KEY, kc.token) + if (kc.refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, kc.refreshToken) + if (kc.idToken) localStorage.setItem(ID_TOKEN_KEY, kc.idToken) +} + +export function clearTokens() { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + localStorage.removeItem(ID_TOKEN_KEY) +} + export default keycloak diff --git a/src/lib/keycloak/refresh.test.ts b/src/lib/keycloak/refresh.test.ts new file mode 100644 index 0000000..1c7b43f --- /dev/null +++ b/src/lib/keycloak/refresh.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockKeycloak = { + updateToken: vi.fn(), + token: undefined as string | undefined, + refreshToken: undefined as string | undefined, + idToken: undefined as string | undefined, +} + +vi.mock('./keycloak', async () => { + const actual = await vi.importActual('./keycloak') + return { + ...actual, + default: mockKeycloak, + } +}) + +// Imported after the mock so it picks up the mocked default export. +const { refreshToken, isTokenExpiringSoon } = await import('./refresh') +const { TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY } = await import('./keycloak') + +function base64UrlEncode(json: object) { + const base64 = btoa(JSON.stringify(json)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function fakeToken(exp: number) { + return `header.${base64UrlEncode({ sub: 'user-1', exp })}.signature` +} + +describe('refreshToken', () => { + beforeEach(() => { + localStorage.clear() + mockKeycloak.updateToken.mockReset() + mockKeycloak.token = undefined + mockKeycloak.refreshToken = undefined + mockKeycloak.idToken = undefined + }) + + it('persists the new tokens on a successful refresh', async () => { + mockKeycloak.updateToken.mockImplementation(async () => { + mockKeycloak.token = 'new-access-token' + mockKeycloak.refreshToken = 'new-refresh-token' + mockKeycloak.idToken = 'new-id-token' + return true + }) + + const result = await refreshToken() + + expect(result).toBe(true) + expect(localStorage.getItem(TOKEN_KEY)).toBe('new-access-token') + expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBe('new-refresh-token') + expect(localStorage.getItem(ID_TOKEN_KEY)).toBe('new-id-token') + }) + + it('clears all stored tokens and resolves false when refresh fails', async () => { + localStorage.setItem(TOKEN_KEY, 'stale-access-token') + localStorage.setItem(REFRESH_TOKEN_KEY, 'stale-refresh-token') + mockKeycloak.updateToken.mockRejectedValue(new Error('invalid_grant')) + + const result = await refreshToken() + + expect(result).toBe(false) + expect(localStorage.getItem(TOKEN_KEY)).toBeNull() + expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull() + }) + + it('retries once after a transient failure before giving up', async () => { + vi.useFakeTimers() + mockKeycloak.updateToken + .mockRejectedValueOnce(new Error('network error')) + .mockRejectedValueOnce(new Error('network error')) + + const resultPromise = refreshToken() + await vi.advanceTimersByTimeAsync(2000) + const result = await resultPromise + + expect(mockKeycloak.updateToken).toHaveBeenCalledTimes(2) + expect(result).toBe(false) + vi.useRealTimers() + }) + + it('succeeds on the retry after one transient failure', async () => { + vi.useFakeTimers() + mockKeycloak.updateToken.mockRejectedValueOnce(new Error('network error')).mockImplementationOnce(async () => { + mockKeycloak.token = 'recovered-token' + return true + }) + + const resultPromise = refreshToken() + await vi.advanceTimersByTimeAsync(2000) + const result = await resultPromise + + expect(mockKeycloak.updateToken).toHaveBeenCalledTimes(2) + expect(result).toBe(true) + expect(localStorage.getItem(TOKEN_KEY)).toBe('recovered-token') + vi.useRealTimers() + }) +}) + +describe('isTokenExpiringSoon', () => { + afterEach(() => { + localStorage.clear() + }) + + it('returns true when there is no stored token', () => { + expect(isTokenExpiringSoon(0)).toBe(true) + }) + + it('returns false for a token with plenty of remaining validity', () => { + const farFutureExp = Math.floor(Date.now() / 1000) + 3600 + localStorage.setItem('kc_token', fakeToken(farFutureExp)) + + expect(isTokenExpiringSoon(5 * 60_000)).toBe(false) + }) + + it('returns true for a token expiring within the given window', () => { + const soonExp = Math.floor(Date.now() / 1000) + 60 + localStorage.setItem('kc_token', fakeToken(soonExp)) + + expect(isTokenExpiringSoon(5 * 60_000)).toBe(true) + }) +}) diff --git a/src/lib/keycloak/refresh.ts b/src/lib/keycloak/refresh.ts new file mode 100644 index 0000000..2845f19 --- /dev/null +++ b/src/lib/keycloak/refresh.ts @@ -0,0 +1,53 @@ +// Framework-agnostic — no React import — so this can be used from both +// KeycloakProvider.tsx and the axios interceptors without a circular import +// between a file that exports React hooks/components and one axios needs. +import keycloak, { decodeJwtPayload, persistTokens, clearTokens, TOKEN_KEY } from './keycloak' + +const MIN_VALIDITY_SECONDS = 300 // refresh if less than 5 min of validity remains +const RETRY_DELAY_MS = 2000 + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function attemptUpdateToken(): Promise { + const refreshed = await keycloak.updateToken(MIN_VALIDITY_SECONDS) + if (refreshed) persistTokens(keycloak) + return true +} + +// Silently refreshes the access token via keycloak-js's own updateToken(), +// which uses the refresh token keycloak-js was hydrated with (see +// Login.tsx / KeycloakProvider.tsx). Never logs token values — only the +// boolean outcome is observable from here. +export async function refreshToken(): Promise { + try { + return await attemptUpdateToken() + } catch { + // Could be a transient network error rather than a truly invalid + // refresh token — give it one more try before giving up. + await delay(RETRY_DELAY_MS) + try { + return await attemptUpdateToken() + } catch { + clearTokens() + return false + } + } +} + +// Pure check against the persisted access token — usable standalone +// (e.g. in ProtectedRoute or tests) without depending on keycloak-js +// instance state, which may not be hydrated yet on a fresh page load. +export function isTokenExpiringSoon(minValidityMs: number): boolean { + const token = localStorage.getItem(TOKEN_KEY) + if (!token) return true + + try { + const payload = decodeJwtPayload(token) + const msUntilExpiry = payload.exp * 1000 - Date.now() + return msUntilExpiry < minValidityMs + } catch { + return true + } +} diff --git a/src/lib/keycloak/useIdleTimer.test.ts b/src/lib/keycloak/useIdleTimer.test.ts new file mode 100644 index 0000000..b116ffa --- /dev/null +++ b/src/lib/keycloak/useIdleTimer.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useIdleTimer } from './useIdleTimer' + +describe('useIdleTimer', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('fires onWarn at idleMs - warnBeforeMs without yet firing onIdle', () => { + const onWarn = vi.fn() + const onIdle = vi.fn() + + renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle })) + + act(() => { + vi.advanceTimersByTime(700) + }) + + expect(onWarn).toHaveBeenCalledTimes(1) + expect(onIdle).not.toHaveBeenCalled() + }) + + it('fires onIdle once the full idle duration elapses', () => { + const onWarn = vi.fn() + const onIdle = vi.fn() + + renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle })) + + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(onIdle).toHaveBeenCalledTimes(1) + }) + + it('restarts the clock when reset() is called', () => { + const onWarn = vi.fn() + const onIdle = vi.fn() + + const { result } = renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle })) + + act(() => { + vi.advanceTimersByTime(600) + result.current.reset() + vi.advanceTimersByTime(600) + }) + + // 1200ms of real elapsed time, but reset() at 600ms means only 600ms + // has passed since the last reset — under the 700ms warn threshold. + expect(onWarn).not.toHaveBeenCalled() + }) + + it('resets on a tracked DOM activity event', () => { + const onWarn = vi.fn() + const onIdle = vi.fn() + + renderHook(() => useIdleTimer({ idleMs: 1000, warnBeforeMs: 300, onWarn, onIdle })) + + act(() => { + vi.advanceTimersByTime(600) + window.dispatchEvent(new Event('keydown')) + vi.advanceTimersByTime(600) + }) + + expect(onWarn).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/keycloak/useIdleTimer.ts b/src/lib/keycloak/useIdleTimer.ts new file mode 100644 index 0000000..b4ec155 --- /dev/null +++ b/src/lib/keycloak/useIdleTimer.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react' +import { on } from '@/lib/events' + +interface UseIdleTimerOptions { + idleMs: number + warnBeforeMs: number + onWarn: () => void + onIdle: () => void +} + +const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'touchstart', 'scroll'] as const + +// Tracks inactivity independent of JWT expiry — driven by real user +// interaction (DOM events) and API activity (the 'activity' event emitted +// by the axios interceptors), not by the access token's exp claim. +export function useIdleTimer({ idleMs, warnBeforeMs, onWarn, onIdle }: UseIdleTimerOptions) { + const warnTimeoutRef = useRef | null>(null) + const idleTimeoutRef = useRef | null>(null) + // Keep the latest callbacks in refs so `reset` doesn't need to change + // identity every time a consumer passes a fresh inline function. Synced + // in an effect, not during render, to avoid mutating a ref while rendering. + const onWarnRef = useRef(onWarn) + const onIdleRef = useRef(onIdle) + useEffect(() => { + onWarnRef.current = onWarn + onIdleRef.current = onIdle + }, [onWarn, onIdle]) + + const reset = () => { + if (warnTimeoutRef.current) clearTimeout(warnTimeoutRef.current) + if (idleTimeoutRef.current) clearTimeout(idleTimeoutRef.current) + + const warnDelay = Math.max(idleMs - warnBeforeMs, 0) + warnTimeoutRef.current = setTimeout(() => onWarnRef.current(), warnDelay) + idleTimeoutRef.current = setTimeout(() => onIdleRef.current(), idleMs) + } + + useEffect(() => { + reset() + + const handleActivity = () => reset() + for (const type of ACTIVITY_EVENTS) { + window.addEventListener(type, handleActivity, { passive: true }) + } + const unsubscribeActivity = on('activity', handleActivity) + + return () => { + if (warnTimeoutRef.current) clearTimeout(warnTimeoutRef.current) + if (idleTimeoutRef.current) clearTimeout(idleTimeoutRef.current) + for (const type of ACTIVITY_EVENTS) { + window.removeEventListener(type, handleActivity) + } + unsubscribeActivity() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idleMs, warnBeforeMs]) + + return { reset } +} diff --git a/src/main.tsx b/src/main.tsx index 45810cd..2b96857 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,8 @@ import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import KeycloakProvider from '@/lib/keycloak/KeycloakProvider' +import ProtectedRoute from '@/components/shared/ProtectedRoute' +import ForbiddenListener from '@/components/shared/ForbiddenListener' import ToastProvider from '@/components/shared/ToastProvider' import AppLayout from '@/components/shared/AppLayout' import SplashScreen from '@/pages/SplashScreen' @@ -26,6 +28,7 @@ import AccountMapperSelfService from '@/pages/AccountMapperSelfService' function AuthRoot() { return ( + ) @@ -39,27 +42,32 @@ const router = createBrowserRouter([ { path: '/login', element: }, { path: '/account-mapper/self-service', element: }, - // Protected routes — wrapped by KeycloakProvider + // Protected routes — wrapped by KeycloakProvider, guarded by ProtectedRoute { element: , children: [ { - path: '/', - element: , + element: , children: [ - { index: true, element: }, - { path: 'payment-hub', element: }, - { path: 'payment-hub/batch/:batchId', element: }, - { path: 'vouchers', element: }, - { path: 'account-mapper', element: }, - { path: 'g2p-config', element: }, - { path: 'settings', element: }, - { path: 'rbac', element: }, - { path: 'reporting', element: }, - { path: 'visualizations', element: }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'payment-hub', element: }, + { path: 'payment-hub/batch/:batchId', element: }, + { path: 'vouchers', element: }, + { path: 'account-mapper', element: }, + { path: 'g2p-config', element: }, + { path: 'settings', element: }, + { path: 'rbac', element: }, + { path: 'reporting', element: }, + { path: 'visualizations', element: }, + ], + }, + { path: '*', element: }, ], }, - { path: '*', element: }, ], }, ]) diff --git a/src/modules/auth/Login.tsx b/src/modules/auth/Login.tsx index 2608995..42b816f 100644 --- a/src/modules/auth/Login.tsx +++ b/src/modules/auth/Login.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { User, Lock, Eye, EyeOff, Loader2 } from 'lucide-react' import { Input } from '@/components/ui/input' @@ -12,11 +12,19 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { useToast } from '@/components/shared/ToastProvider' +import keycloak, { persistTokens } from '@/lib/keycloak/keycloak' const TENANTS = ['greenbank', 'bluebank', 'redbank'] +const LOGOUT_REASON_MESSAGES: Record = { + inactivity: 'Session ended due to inactivity.', + expired: 'Your session expired. Please log in again.', +} + export default function Login() { const navigate = useNavigate() + const { toast } = useToast() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [tenant, setTenant] = useState('') @@ -25,6 +33,18 @@ export default function Login() { const [loading, setLoading] = useState(false) const [error, setError] = useState('') + // Surface why the user landed back here after a hard logout redirect — + // no in-memory state survives that navigation, so this is passed via + // sessionStorage by KeycloakProvider/authInterceptors. + useEffect(() => { + const reason = sessionStorage.getItem('logout_reason') + if (reason) { + toast(LOGOUT_REASON_MESSAGES[reason] ?? 'Session ended.', 'warning') + sessionStorage.removeItem('logout_reason') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError('') @@ -46,7 +66,16 @@ export default function Login() { ) const data = await response.json() if (data.access_token) { - localStorage.setItem('kc_token', data.access_token) + // Hand the ROPC tokens to the shared keycloak-js instance so its own + // updateToken()/logout() have a real refresh/id token to work with — + // without this, keycloak.token stays undefined forever and silent + // refresh has nothing to refresh. + await keycloak.init({ + token: data.access_token, + refreshToken: data.refresh_token, + idToken: data.id_token, + }) + persistTokens(keycloak) localStorage.setItem('tenant', tenant) navigate('/') } else {