Skip to content
Merged
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
25 changes: 25 additions & 0 deletions web-client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,33 @@
if (isDark) document.documentElement.classList.add('dark');
})();
</script>
<style>
#splash {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--background, #f7f7fa);
}
.dark #splash {
background: oklch(0.141 0.005 285.823);
}
#splash-spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid oklch(0.768 0.233 130.85);
border-top-color: transparent;
animation: splash-spin 0.7s linear infinite;
}
@keyframes splash-spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="splash"><div id="splash-spinner"></div></div>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
Expand Down
11 changes: 11 additions & 0 deletions web-client/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ server {
# proxy_set_header X-Forwarded-Proto $scheme;
# }

# Keycloak silent-check-sso: must be loadable in a hidden iframe.
# X-Frame-Options: DENY (set above with `always`) would block the iframe,
# preventing keycloak.js from receiving the postMessage and causing a timeout.
# Nginx does NOT inherit parent add_header directives in child blocks, so we
# re-declare the other security headers explicitly and omit X-Frame-Options.
location = /silent-check-sso.html {
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
try_files $uri =404;
}

# SPA routing: anything that isn't a real file falls back to index.html
location / {
try_files $uri $uri/ /index.html;
Expand Down
6 changes: 6 additions & 0 deletions web-client/public/silent-check-sso.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!doctype html>
<html>
<body>
<script>parent.postMessage(location.href, location.origin)</script>
</body>
</html>
137 changes: 137 additions & 0 deletions web-client/src/AuthenticatedApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from 'react'
import { AlertCircle } from 'lucide-react'
import App from '@/App'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import keycloak, { KEYCLOAK_URL, TOKEN_REFRESH_MIN_VALIDITY_SECONDS } from '@/lib/keycloak'
import {
AUTH_INIT_TIMEOUT_MS,
classifyAuthError,
type AuthError,
withTimeout,
} from '@/lib/auth-bootstrap'

function removeSplash() {
document.getElementById('splash')?.remove()
}

export default function AuthenticatedApp() {
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [authError, setAuthError] = useState<AuthError | null>(null)
const didInitRef = useRef(false)

useEffect(() => {
removeSplash()
}, [])

useEffect(() => {
if (didInitRef.current) {
return
}

didInitRef.current = true

async function initializeAuth() {
try {
const authenticated = await withTimeout(
keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
pkceMethod: 'S256',
checkLoginIframe: false,
}),
AUTH_INIT_TIMEOUT_MS,
)

if (!authenticated) {
await keycloak.login()
return
}

setAuthError(null)
setStatus('ready')
} catch (error) {
setAuthError(classifyAuthError(error))
setStatus('error')
}
}

void initializeAuth()

return () => {
}
}, [])

useEffect(() => {
if (status !== 'ready') {
return
}

keycloak.onTokenExpired = () => {
keycloak.updateToken(TOKEN_REFRESH_MIN_VALIDITY_SECONDS).catch(() => undefined)
}

return () => {
keycloak.onTokenExpired = undefined
}
}, [status])

if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center">
<LoadingSpinner />
</div>
)
}

if (status === 'error') {
const messages: Record<AuthError, { title: string; description: string }> = {
network: {
title: 'Cannot reach authentication server',
description: 'Check your connection and try again.',
},
config: {
title: 'Authentication misconfigured',
description: 'Contact your administrator — the auth server may be misconfigured.',
},
timeout: {
title: 'Authentication is stuck',
description: `Sign-in never finished. Verify that Keycloak is reachable at ${KEYCLOAK_URL} and that the login redirect is not being blocked.`,
},
unknown: {
title: 'Authentication failed',
description: 'An unexpected error occurred. Please try again.',
},
}
const { title, description } = messages[authError ?? 'unknown']

return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Sign-in error</AlertTitle>
<AlertDescription>
If this keeps happening, contact support.
</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button className="w-full" onClick={() => window.location.reload()}>
Try again
</Button>
</CardFooter>
</Card>
</div>
)
}

return <App />
}
196 changes: 196 additions & 0 deletions web-client/src/__tests__/AuthenticatedApp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { StrictMode, act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const keycloakMock = {
init: vi.fn<(options?: unknown) => Promise<boolean>>(),
login: vi.fn<() => Promise<void>>(),
updateToken: vi.fn<(minValidity: number) => Promise<boolean>>(),
onTokenExpired: undefined as (() => void) | undefined,
}

vi.mock('@/App', () => ({
default: () => <div>App ready</div>,
}))

vi.mock('@/lib/keycloak', () => ({
KEYCLOAK_URL: 'http://keycloak.test',
TOKEN_REFRESH_MIN_VALIDITY_SECONDS: 30,
default: keycloakMock,
}))

const { default: AuthenticatedApp } = await import('@/AuthenticatedApp')

describe('AuthenticatedApp', () => {
let container: HTMLDivElement
let root: Root

beforeEach(() => {
vi.clearAllMocks()
keycloakMock.onTokenExpired = undefined
keycloakMock.login.mockResolvedValue(undefined)
keycloakMock.updateToken.mockResolvedValue(true)
document.body.innerHTML = '<div id="root"></div><div id="splash"></div>'
container = document.getElementById('root') as HTMLDivElement
root = createRoot(container)
})

afterEach(async () => {
await act(async () => {
root.unmount()
})
document.body.innerHTML = ''
})

async function render() {
await act(async () => {
root.render(<AuthenticatedApp />)
// flush microtasks so the async init chain resolves
await new Promise((r) => setTimeout(r, 0))
})
}

async function renderInStrictMode() {
await act(async () => {
root.render(
<StrictMode>
<AuthenticatedApp />
</StrictMode>,
)
await new Promise((r) => setTimeout(r, 0))
})
}

// ---------------------------------------------------------------------------
// Happy path
// ---------------------------------------------------------------------------

it('renders the app when keycloak init resolves authenticated', async () => {
keycloakMock.init.mockResolvedValue(true)

await render()

expect(container.textContent).toContain('App ready')
})

it('removes the splash screen on successful auth', async () => {
keycloakMock.init.mockResolvedValue(true)

await render()

expect(document.getElementById('splash')).toBeNull()
})

it('registers onTokenExpired handler after successful auth', async () => {
keycloakMock.init.mockResolvedValue(true)

await render()

expect(keycloakMock.onTokenExpired).toBeTypeOf('function')
})

it('re-registers onTokenExpired after StrictMode effect cleanup without re-initializing auth', async () => {
keycloakMock.init.mockResolvedValue(true)

await renderInStrictMode()

expect(keycloakMock.init).toHaveBeenCalledTimes(1)
expect(keycloakMock.onTokenExpired).toBeTypeOf('function')
})

it('calls updateToken(30) when token expires', async () => {
keycloakMock.init.mockResolvedValue(true)

await render()

await act(async () => {
keycloakMock.onTokenExpired?.()
await new Promise((r) => setTimeout(r, 0))
})

expect(keycloakMock.updateToken).toHaveBeenCalledWith(30)
})

it('does not call login() when token refresh fails on expiry', async () => {
keycloakMock.init.mockResolvedValue(true)
keycloakMock.updateToken.mockRejectedValue(new Error('session gone'))

await render()

await act(async () => {
keycloakMock.onTokenExpired?.()
await new Promise((r) => setTimeout(r, 0))
})

expect(keycloakMock.login).not.toHaveBeenCalled()
})

// ---------------------------------------------------------------------------
// Not-authenticated path (redirect)
// ---------------------------------------------------------------------------

it('calls keycloak.login() when init resolves not-authenticated', async () => {
keycloakMock.init.mockResolvedValue(false)

await render()

expect(keycloakMock.login).toHaveBeenCalledTimes(1)
expect(container.textContent).not.toContain('App ready')
})

// ---------------------------------------------------------------------------
// Error paths — each error type maps to the right heading
// ---------------------------------------------------------------------------

it('shows network error heading when fetch fails', async () => {
keycloakMock.init.mockRejectedValue(new Error('Failed to fetch'))

await render()

expect(container.textContent).toContain('Cannot reach authentication server')
expect(container.textContent).not.toContain('App ready')
})

it('shows config error heading when realm is invalid', async () => {
keycloakMock.init.mockRejectedValue(new Error('realm not found'))

await render()

expect(container.textContent).toContain('Authentication misconfigured')
})

it('shows timeout error heading and keycloak URL when init times out', async () => {
keycloakMock.init.mockRejectedValue(
new Error('Authentication timed out after 15000ms while contacting http://keycloak.test'),
)

await render()

expect(container.textContent).toContain('Authentication is stuck')
expect(container.textContent).toContain('http://keycloak.test')
})

it('shows generic error heading for unknown failures', async () => {
keycloakMock.init.mockRejectedValue(new Error('something completely unexpected'))

await render()

expect(container.textContent).toContain('Authentication failed')
})

it('removes the splash screen on auth error', async () => {
keycloakMock.init.mockRejectedValue(new Error('Failed to fetch'))

await render()

expect(document.getElementById('splash')).toBeNull()
})

it('shows a retry button on auth error', async () => {
keycloakMock.init.mockRejectedValue(new Error('Failed to fetch'))

await render()

expect(container.textContent).toContain('Try again')
})
})
Loading
Loading