Skip to content

Add Keycloak authentication to smartem app#74

Draft
vredchenko wants to merge 3 commits into
mainfrom
64-keycloak-auth-v2
Draft

Add Keycloak authentication to smartem app#74
vredchenko wants to merge 3 commits into
mainfrom
64-keycloak-auth-v2

Conversation

@vredchenko
Copy link
Copy Markdown
Collaborator

@vredchenko vredchenko commented Mar 25, 2026

Summary

  • Add keycloak-js-based auth infrastructure to the smartem app with a thin AuthProvider, useAuth() hook, and automatic token refresh
  • Wire auth tokens into the shared Axios interceptor (@smartem/api) so all API calls include Authorization: Bearer headers when authenticated
  • Add conditional AuthGate wrapper — auth is disabled by default in dev/mock mode (VITE_ENABLE_MOCKS=true), enabled in production
  • Replace prototyping RoleSwitcher in the dashboard Header with real Keycloak auth controls (sign in icon / account menu with sign out)
  • Register smartem-frontend with Keycloak https://dev-guide.diamond.ac.uk/authn/how-tos/request-a-registration-with-keycloak/

Configuration

Requires env vars (see apps/smartem/.env.example):

VITE_KEYCLOAK_URL=https://identity.diamond.ac.uk
VITE_KEYCLOAK_REALM=master
VITE_KEYCLOAK_CLIENT_ID=smartem-frontend
VITE_AUTH_ENABLED=false

Keycloak client registration has not been done yet — these are placeholders ready to be filled in.

Scope

This PR covers the frontend auth ceremony only. The SPA authenticates directly with Keycloak and attaches tokens to API requests. The backend does not need changes for this to work — it simply ignores the tokens until backend token validation is added separately.

Route protection and backend auth are separate concerns to be addressed in follow-up work.

Supersedes #70 (reimplemented on top of the dashboard concept from #73).

Test plan

  • npm run dev:smartem:mock — app loads normally, no auth UI shown (auth disabled in mock mode)
  • VITE_AUTH_ENABLED=true npm run dev:smartem with valid Keycloak config — login flow works
  • Header shows sign-in icon when unauthenticated, account menu when authenticated
  • Verify token is attached to API requests after login
  • Verify sign out clears token and returns to unauthenticated state
  • npm run typecheck passes
  • npm run build:smartem succeeds

Closes #64

@vredchenko vredchenko added the development New features or functionality implementation label Mar 25, 2026
@vredchenko vredchenko marked this pull request as draft March 25, 2026 21:37
@vredchenko vredchenko force-pushed the 64-keycloak-auth-v2 branch 2 times, most recently from ceeb4cb to 98776aa Compare April 1, 2026 12:28
@vredchenko
Copy link
Copy Markdown
Collaborator Author

@vredchenko vredchenko force-pushed the 64-keycloak-auth-v2 branch from 98776aa to 6f5a425 Compare April 20, 2026 09:06
@vredchenko vredchenko force-pushed the 64-keycloak-auth-v2 branch 2 times, most recently from e24e6b3 to 8d42d2c Compare April 29, 2026 14:33
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.
Content recycled to smartem-frontend #39 (tier 3 code examples and
re-evaluation comment) and smartem-devtools PR #160 (NFR suggestions).
The file analysed the legacy app and is superseded by the curated
roadmap in issue #39.
@vredchenko vredchenko force-pushed the 64-keycloak-auth-v2 branch from 8d42d2c to f95be88 Compare May 12, 2026 11:07
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.
@vredchenko
Copy link
Copy Markdown
Collaborator Author

Review notes — local dev exercise of the auth flow

I took the branch for a spin against the DLS identity-test realm and then against a locally-run Keycloak. Three things came up that are worth addressing before this merges.

1. Bug: init failure permanently bricks the login button

In apps/smartem/src/auth/AuthProvider.tsx, the init error path spreads defaultAuth, whose login and logout are no-ops:

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 .login() / .logout() methods, but the context never receives them. Any transient init failure — iframe blocked by CORS, network blip, Keycloak slow to respond, misconfigured Web Origins — leaves the user with a Sign in button that does nothing until they reload (and reload doesn't help if the failure is persistent).

I hit this against identity-test: the silent-SSO iframe returned 403 because http://localhost:5173 isn't in the SmartEM client's Web Origins. The catch ran, the button rendered, clicking it called the no-op login().

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',
  })
})

keycloak.login() does a full-page redirect and doesn't depend on the silent-SSO iframe, so it works even when the init handshake failed.

2. Design issue: onLoad: 'check-sso' without a silent-SSO HTML page

keycloak.init({ onLoad: 'check-sso' }) is called with no silentCheckSsoRedirectUri. Per the keycloak-js docs, this means the silent-SSO check falls back to a top-level redirect to Keycloak when the iframe approach isn't viable.

Combined with React.StrictMode (which double-mounts effects in dev), the result is a redirect storm: page → Keycloak /auth?… → page (with #error=login_required) → effect re-runs → page → Keycloak → page → … The app never gets a chance to render the header, so the user can't reach the Sign in button at all.

I observed this against the local Keycloak in this exercise — three full reload cycles in 5s, header never appears.

Suggested fix — add a static apps/smartem/public/silent-check-sso.html:

<!doctype html>
<html><body><script>
  parent.postMessage(location.href, location.origin)
</script></body></html>

and pass its URL in init:

keycloak.init({
  onLoad: 'check-sso',
  silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
  pkceMethod: 'S256',
})

This keeps the silent-SSO check inside an iframe (no top-level redirect), so the StrictMode double-mount is harmless. Worth also adding pkceMethod: 'S256' explicitly — the request currently uses it but only because the library defaults are kind.

3. Documentation gap: no local-dev setup

The README doesn't mention Keycloak. apps/smartem/.env.example points at identity-test.diamond.ac.uk, but the SmartEM client there doesn't have http://localhost:5173 in its Web Origins or Valid Redirect URIs, so out-of-the-box npm run dev:smartem fails immediately with a 403 on the silent-SSO iframe.

smartem-devtools/docs/architecture/keycloak-spa-authentication.md describes the design but predates this PR and refers to a smartem-frontend client; the implementation uses SmartEM. Worth aligning the names.

To unblock local dev I drafted a self-contained Keycloak mock in smartem-devtools/keycloak-mock/:

  • docker-compose.yml — Keycloak 26 in start-dev --import-realm mode, port 8080
  • realm/dls-realm.json — realm dls, public client SmartEM with PKCE, redirect URIs and Web Origins for localhost:5173 and :5174, a custom fedId claim mapper, two seeded users

Devs point VITE_KEYCLOAK_URL=http://localhost:8080 in .env.local, docker compose up -d, done.

This pairs with a one-line README section. Happy to fold it into this PR or land it as a follow-up.

Verifying the fixes

I patched issues 1 and 2 locally (reverted in current state) and ran the full flow against the local Keycloak mock: app loads → Sign in → redirect to localhost:8080/realms/dls/... → submit credentials → redirect back with auth code → tokens exchanged → header shows account menu with the logged-in user. End-to-end works once both fixes are applied.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

development New features or functionality implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement user authentication against Keycloak

1 participant