Add Keycloak authentication to smartem app#74
Conversation
ceeb4cb to
98776aa
Compare
98776aa to
6f5a425
Compare
e24e6b3 to
8d42d2c
Compare
Add keycloak-js auth infrastructure with AuthProvider, useAuth() hook, and automatic token refresh. Wire tokens into the shared Axios interceptor so all API calls include Bearer headers when authenticated. Replace the prototyping RoleSwitcher in the Header with real auth controls (sign in / account menu / sign out). Auth is disabled by default in dev/mock mode.
8d42d2c to
f95be88
Compare
Helpdesk UASHD-4189 registered the SmartEM app against the dls realm on identity.diamond.ac.uk (prod) and identity-test.diamond.ac.uk (test) with client ID SmartEM. Replaces the placeholder master / smartem-frontend values from initial scaffolding. .env.example defaults to the test environment for local dev; the in-code fallback in config.ts stays on prod so a production build without env vars set doesn't silently point at the test realm.
Review notes — local dev exercise of the auth flowI took the branch for a spin against the DLS 1. Bug: init failure permanently bricks the login buttonIn const defaultAuth: Auth = {
initialised: false,
authenticated: false,
login: () => {}, // no-op
logout: () => {}, // no-op
getToken: () => '',
}
// …
keycloak
.init({ onLoad: 'check-sso' })
.then(() => setAuth(buildAuth(keycloak)))
.catch((err) => {
console.error('Keycloak init failed:', err)
setAuth({ ...defaultAuth, initialised: true, error: 'Failed to connect to Keycloak' })
})The keycloak instance is alive in the closure with working I hit this against Suggested fix — use the live keycloak instance so login can still redirect: .catch((err) => {
console.error('Keycloak init failed:', err)
setAuth({
...buildAuth(keycloak),
initialised: true,
error: 'Failed to connect to Keycloak',
})
})
2. Design issue:
|
Add silent-check-sso.html so keycloak-js performs the session check in a hidden iframe instead of a top-level redirect, preventing the redirect storm caused by React StrictMode double-mounting the init effect. Use the live keycloak instance in the init error path so login() still triggers a full-page redirect even when the silent-SSO handshake fails.
Adding `silentCheckSsoRedirectUri` covered the initial check-sso, but
keycloak-js still defaults `checkLoginIframe: true` - a hidden iframe that
polls Keycloak every 5 seconds for session state. In modern browsers third-
party cookies are blocked, so that iframe can never read Keycloak's session
cookie; it reports "session changed" each tick, which keycloak-js translates
into a top-level redirect. Result: the SPA bounces between `/` and
`/#error=login_required` every five seconds.
The companion option `silentCheckSsoFallback` (default true) made the same
thing happen for the initial check: if the silent iframe couldn't see the
session cookie, keycloak-js fell back to a top-level redirect. Combined
with React StrictMode's double-mount of the AuthProvider effect, the
fallback produced an immediate two-redirect storm even before the polling
iframe kicked in.
Disable both:
- `silentCheckSsoFallback: false` keeps the initial check confined to
the iframe; if that fails, the user just sees the sign-in button and
has to click it (rather than getting redirect-stormed).
- `checkLoginIframe: false` stops the post-init polling entirely. We
lose cross-tab logout detection from this path, but the token-refresh
timer already detects an invalidated session on its next tick.
Verified end-to-end against the local Keycloak mock: 0 spurious redirects
during settle, click → KC login → return → authenticated state with the
account menu visible.
`AuthGate` was previously a pure pass-through wrapper around `AuthProvider`
- it set up the auth context but always rendered children, regardless of
auth state. Unauthenticated users could see the entire dashboard with just
a "Sign in" button in the header.
Add an `AuthBoundary` inner component that gates rendering on the resolved
auth state:
- `!auth.initialised`: render nothing. Keycloak init is in flight; better
to show a brief blank than flash either the sign-in screen or the app.
- `!auth.authenticated`: render `SignInScreen` - a centred MUI Box with
the app title, a one-line prompt, and a contained "Sign in" button
that calls `auth.login()`. If `auth.error` is populated (init failed,
refresh failed, etc.) it is surfaced beneath the button in `error.main`
colour.
- authenticated: render children as before.
When `VITE_AUTH_ENABLED=false` the gate is a no-op (the early return at the
top of `AuthGate` is unchanged), so mock-mode UI work is unaffected.
Verified end-to-end against the local Keycloak mock: pre-login the only
visible content is the sign-in screen; clicking through to Keycloak and
back unlocks the dashboard.
Summary
End-to-end Keycloak authentication for the
smartemapp:keycloak-js-basedAuthProvider,useAuth()hook, automatic token-refresh scheduling@smartem/apiattachesAuthorization: Bearer <token>to every API call when authenticatedAuthGatehard-blocks the dashboard until Keycloak has confirmed authentication — unauthenticated visitors only see a sign-in screen, never the app contentsHeaderswaps the prototypingRoleSwitcherfor real auth controls (sign-in icon / account menu with sign-out)VITE_AUTH_ENABLED=falseorVITE_ENABLE_MOCKS=true), enabled in production buildsCloses #64. Supersedes #70.
Configuration
Defaults to the DLS test realm;
apps/smartem/.env.example:The in-code fallback in
config.tspoints at production (identity.diamond.ac.uk) so a build without env vars set doesn't silently point at the test realm. Helpdesk UASHD-4189 registered theSmartEMclient against thedlsrealm on bothidentity.diamond.ac.ukandidentity-test.diamond.ac.uk.For local development without DLS identity access, see DiamondLightSource/smartem-devtools#198 — a self-contained Keycloak mock with the same realm/client/PKCE config.
Fixes that landed during E2E shakeout
While exercising the flow end-to-end against the local mock from #198, three issues surfaced and got fixed on this branch:
557edf6) — addedsilentCheckSsoRedirectUri+ a publicsilent-check-sso.htmlthat posts the parsed URL back to the parent. Without it,check-ssodid a top-level redirect on every load.557edf6) — the catch handler was buildingAuthfromdefaultAuth, soauth.login()did nothing if init failed. Now built from the livekeycloakinstance.6b02a4a) —keycloak-jsdefaultscheckLoginIframe: true, polling Keycloak every 5s via a hidden iframe. Modern browsers block third-party cookies, so the iframe can never read Keycloak's session cookie; it reports "session changed" each tick, andkeycloak-jstranslates that into a top-level redirect. The companion optionsilentCheckSsoFallback(defaulttrue) caused the initial check to fall back to a top-level redirect when the iframe couldn't see the session. Both disabled.2ee26bd) —AuthGatepreviously rendered children regardless of auth state, so unauthenticated users saw the dashboard with a sign-in button in the corner. Now wraps children in anAuthBoundarythat renders nothing while init is in flight, a centred sign-in screen when unauthenticated, and the app when authenticated. No-op whenVITE_AUTH_ENABLED=false.Scope
Frontend auth ceremony only. The SPA authenticates with Keycloak directly and attaches tokens to API requests. Backend validation is a separate change in DiamondLightSource/smartem-decisions (the JWT-validation PR is in flight).
Route-level RBAC and per-feature permission gating are deferred to a follow-up.
Test plan
npm run dev:smartem:mock— app loads normally, no auth UI, mock data visibleVITE_AUTH_ENABLED=true npm run dev:smartemagainst the local Keycloak mock — sign-in screen renders pre-login, full app renders after login#error=login_requiredredirect loop on idle pagesnpm run typecheckpasses (CI green on every push)npm run build:smartemsucceeds (CI green on every push)