Skip to content

ADR-002: Keycloakify Migration

Status: ✅ Complete Date: 2025-10 Deciders: Frontend Team, DevOps Lead Related: ADR-001, Frontend Architecture

Context

Initially, we used Keycloak's default Freemarker templates for the login and registration UI. These templates were stored in backend/keycloak/themes/noumaris-old/ and required:

  • Learning Freemarker template syntax
  • Separate styling from our React codebase
  • Manual CSS management
  • Limited TypeScript support
  • Difficult to maintain consistency with main app

As our frontend evolved with React, TailwindCSS, and modern tooling, the disconnect between our main app and the Keycloak theme became a maintenance burden.

Decision

Migrate from Freemarker templates to Keycloakify - A React-based Keycloak theme framework.

What Changed

Old Approach (Freemarker):

backend/keycloak/themes/noumaris-old/
├── login/
│   ├── login.ftl
│   ├── register.ftl
│   └── resources/
│       └── css/
│           └── login.css

New Approach (Keycloakify):

frontend/src/keycloak-theme/
├── kc.gen.tsx          # Auto-generated
└── login/
    ├── KcPage.jsx      # Main theme router
    ├── KcContext.jsx   # Context definitions
    ├── KcApp.jsx       # Dev preview wrapper
    └── pages/
        ├── Login.jsx   # Custom login page
        └── Register.jsx # Custom registration page

Rationale

Alternatives Considered

Option 1: Keep Freemarker, Improve Styling

Pros:

  • No migration needed
  • Familiar to backend team

Cons:

  • Still requires Freemarker knowledge
  • CSS separate from TailwindCSS
  • Can't reuse React components
  • No TypeScript support
  • Difficult to preview changes

Verdict: ❌ Rejected - Doesn't solve core problems

Option 2: Headless Keycloak with Custom Frontend

Pros:

  • Full control over auth UI
  • Pure React implementation
  • No Keycloak theme constraints

Cons:

  • Much more complex implementation
  • Need to handle all OAuth flows manually
  • Security risk if implemented incorrectly
  • Lose Keycloak's battle-tested auth flows
  • More maintenance burden

Verdict: ❌ Rejected - Too risky, over-engineered

Option 3: Keycloakify (SELECTED)

Pros:

  • React-based - Use familiar React patterns
  • TailwindCSS - Consistent styling with main app
  • TypeScript - Type-safe development
  • Component Reuse - Share components with main app
  • Hot Reload - Fast development cycle
  • Preview Mode - Test theme without Keycloak
  • JAR Output - Standard Keycloak deployment
  • Active Community - v11 with good support

Cons:

  • Migration effort (~2 days)
  • New build step (JAR generation)
  • Learning Keycloakify API

Verdict:SELECTED

Consequences

Positive

  1. Consistent Design: Same TailwindCSS utilities as main app
  2. Faster Development: React + hot reload vs Freemarker editing
  3. Type Safety: TypeScript catches errors at compile time
  4. Better DX: Familiar React patterns instead of Freemarker
  5. Component Reuse: Can use Button, Input components from main app
  6. Easier Maintenance: One stack (React) instead of two (React + Freemarker)
  7. Preview Mode: Test theme changes without running Keycloak

Negative

  1. Build Complexity: Extra build step to generate JAR files
  2. JAR Deployment: Need to copy JAR to Keycloak providers directory
  3. Learning Curve: Team needs to learn Keycloakify API
  4. Two JARs: Separate builds for KC 22-25 vs KC 26+

Mitigations

  1. Documentation: Created frontend/KEYCLOAK_THEME.md guide
  2. Build Scripts: Added npm run build-keycloak-theme command
  3. Preview Mode: npm run keycloak-theme:dev for local testing
  4. Deployment Guide: Step-by-step instructions for JAR deployment

Implementation

Migration Steps

  1. ✅ Install Keycloakify: npm install keycloakify@^11
  2. ✅ Create src/keycloak-theme/ directory structure
  3. ✅ Set up KcPage router for page selection
  4. ✅ Implement Login.jsx with TailwindCSS
  5. ✅ Implement Register.jsx with TailwindCSS
  6. ✅ Configure vite.config.ts for Keycloakify build
  7. ✅ Add build script to package.json
  8. ✅ Test locally in Keycloak Docker
  9. ✅ Document in KEYCLOAK_THEME.md
  10. ✅ Remove old Freemarker theme from backend/

Build Process

bash
# Development (preview mode)
cd frontend
npm run keycloak-theme:dev  # Opens browser to test theme

# Production (build JARs)
npm run build-keycloak-theme

# Output:
# frontend/dist_keycloak/
# ├── keycloak-theme-for-kc-22-to-25.jar        # For Keycloak 22-25
# └── keycloak-theme-for-kc-all-other-versions.jar  # For Keycloak 26+

Deployment

bash
# Local Docker
docker cp frontend/dist_keycloak/keycloak-theme-for-kc-22-to-25.jar \
  keycloak:/opt/keycloak/providers/
docker-compose restart keycloak

# Production Cloud Run
gcloud run deploy keycloak \
  --image gcr.io/noumaris/keycloak:latest \
  --update-env-vars KEYCLOAK_THEME_PATH=/opt/keycloak/providers/

Theme Selection

In Keycloak Admin Console:

  1. Navigate to Realm Settings → Themes
  2. Set "Login Theme" to noumaris
  3. Save

Code Examples

Before (Freemarker)

html
<!-- login.ftl -->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=social.displayInfo; section>
    <#if section = "form">
    <div id="kc-form">
      <div id="kc-form-wrapper">
        <form id="kc-form-login" action="${url.loginAction}" method="post">
          <div class="${properties.kcFormGroupClass!}">
            <label for="username" class="${properties.kcLabelClass!}">
              ${msg("usernameOrEmail")}
            </label>
            <input tabindex="1" id="username" name="username" type="text"/>
          </div>
        </form>
      </div>
    </div>
    </#if>
</@layout.registrationLayout>

After (Keycloakify/React)

jsx
// Login.jsx
import { useState } from 'react';
import { useKcMessage } from 'keycloakify';

export default function Login({ kcContext }) {
  const { msg } = useKcMessage();
  const [username, setUsername] = useState('');

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8">
        <h2 className="text-3xl font-bold text-gray-900">
          {msg("loginTitle")}
        </h2>
        <form method="post" action={kcContext.url.loginAction}>
          <div className="space-y-4">
            <div>
              <label className="block text-sm font-medium text-gray-700">
                {msg("usernameOrEmail")}
              </label>
              <input
                type="text"
                name="username"
                className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
              />
            </div>
            <button
              type="submit"
              className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
            >
              {msg("doLogIn")}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

Benefits Realized

Development Speed

  • Before: 30 minutes to update login button color (edit CSS, restart Keycloak, test)
  • After: 2 minutes (change TailwindCSS class, hot reload, test in browser)

Design Consistency

  • Before: Login page looked different from main app
  • After: Seamless transition from login → app (same design system)

Type Safety

  • Before: 0% type coverage in Freemarker templates
  • After: 100% TypeScript coverage in theme

Lessons Learned

  1. Preview Mode is Essential: Being able to test without Keycloak running saved hours
  2. JAR Versioning Matters: Keep old JAR versions in git history for rollback
  3. i18n Still Works: Keycloakify preserves Keycloak's internationalization
  4. Custom Components: Can build shared component library for theme + main app

Future Enhancements

  • [ ] Add Keycloak theme to main app's Storybook
  • [ ] Create more custom pages (forgot-password, email-verification)
  • [ ] Add loading skeletons for better UX
  • [ ] Implement custom error pages
  • [ ] Add analytics tracking to auth flows

References

Internal documentation for Noumaris platform