Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
21 changes: 21 additions & 0 deletions src/components/shared/ForbiddenListener.tsx
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions src/components/shared/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate to="/login" replace state={{ from: location }} />
}

return children ?? <Outlet />
}
48 changes: 48 additions & 0 deletions src/components/shared/SessionTimeoutModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={(next) => { if (!next) onLogoutNow() }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Session about to expire</DialogTitle>
</DialogHeader>

<div className="px-6 pb-2 text-sm text-muted-foreground">
You have been inactive for a while. For your security, you will be
signed out in 2 minutes unless you choose to stay.
</div>

<DialogFooter className="px-0 pb-0">
<Button type="button" variant="outline" onClick={onLogoutNow}>
Log out
</Button>
<Button
type="button"
className="bg-[#1565C0] hover:bg-[#0d47a1] text-white"
onClick={onStayLoggedIn}
>
Stay logged in
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
106 changes: 106 additions & 0 deletions src/lib/api/authInterceptors.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
67 changes: 67 additions & 0 deletions src/lib/api/authInterceptors.ts
Original file line number Diff line number Diff line change
@@ -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)
}
)
}
19 changes: 2 additions & 17 deletions src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/lib/api/g2pConfig.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
15 changes: 15 additions & 0 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading