Claude-skill-registry keycloak-theming
Multi-tenant Keycloak authentication theming with realm-specific design systems
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/keycloak-theming" ~/.claude/skills/majiayu000-claude-skill-registry-keycloak-theming && rm -rf "$T"
manifest:
skills/data/keycloak-theming/SKILL.mdtags
source content
Keycloak Theming Skill
Multi-tenant Keycloak authentication theming with realm-specific design systems.
Overview
This skill provides comprehensive guidance for implementing custom Keycloak themes across multiple realms with tenant-specific branding and design systems.
Multi-Realm Configuration
Realm Structure
Keycloak Instance ├── thelobbi (Realm) │ ├── Theme: lobbi-theme │ ├── Primary Color: #0066cc │ ├── Logo: lobbi-logo.svg │ └── Use Case: Main platform authentication │ └── brooksidebi (Realm) ├── Theme: brookside-theme ├── Primary Color: #2c5282 ├── Logo: brookside-logo.svg └── Use Case: BI platform authentication
Realm Configuration
thelobbi Realm:
{ "realm": "thelobbi", "displayName": "The Lobbi", "displayNameHtml": "<div class=\"kc-logo-text\"><span>The Lobbi</span></div>", "loginTheme": "lobbi-theme", "accountTheme": "lobbi-theme", "adminTheme": "keycloak.v2", "emailTheme": "lobbi-theme" }
brooksidebi Realm:
{ "realm": "brooksidebi", "displayName": "Brookside BI", "displayNameHtml": "<div class=\"kc-logo-text\"><span>Brookside BI</span></div>", "loginTheme": "brookside-theme", "accountTheme": "brookside-theme", "adminTheme": "keycloak.v2", "emailTheme": "brookside-theme" }
Custom Theme Structure
Directory Layout
keycloak/ └── themes/ ├── lobbi-theme/ │ ├── login/ │ │ ├── theme.properties │ │ ├── resources/ │ │ │ ├── css/ │ │ │ │ ├── login.css │ │ │ │ └── styles.css │ │ │ ├── img/ │ │ │ │ ├── lobbi-logo.svg │ │ │ │ ├── lobbi-icon.svg │ │ │ │ └── background.jpg │ │ │ └── js/ │ │ │ └── script.js │ │ └── login.ftl │ │ │ ├── account/ │ │ └── theme.properties │ │ │ └── email/ │ └── theme.properties │ └── brookside-theme/ └── [same structure as lobbi-theme]
Theme Properties
themes/lobbi-theme/login/theme.properties:
parent=keycloak import=common/keycloak styles=css/login.css css/styles.css stylesCommon=node_modules/patternfly/dist/css/patternfly.min.css meta=viewport==width=device-width,initial-scale=1
Login Page Theming Templates (FTL)
Base Login Template
themes/lobbi-theme/login/login.ftl:
<#import "template.ftl" as layout> <@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section> <#if section = "header"> ${msg("loginAccountTitle")} <#elseif section = "form"> <div id="kc-form"> <div id="kc-form-wrapper"> <#if realm.password> <form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post"> <div class="${properties.kcFormGroupClass!}"> <label for="username" class="${properties.kcLabelClass!}"> <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if> </label> <input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="off" aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>" /> <#if messagesPerField.existsError('username','password')> <span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite"> ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} </span> </#if> </div> <div class="${properties.kcFormGroupClass!}"> <label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label> <input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off" aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>" /> </div> <div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}"> <div id="kc-form-options"> <#if realm.rememberMe && !usernameEditDisabled??> <div class="checkbox"> <label> <#if login.rememberMe??> <input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")} <#else> <input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")} </#if> </label> </div> </#if> </div> <div class="${properties.kcFormOptionsWrapperClass!}"> <#if realm.resetPasswordAllowed> <span><a tabindex="5" href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span> </#if> </div> </div> <div id="kc-form-buttons" class="${properties.kcFormGroupClass!}"> <input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/> <input tabindex="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/> </div> </form> </#if> </div> </div> <#elseif section = "info" > <#if realm.password && realm.registrationAllowed && !registrationDisabled??> <div id="kc-registration-container"> <div id="kc-registration"> <span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span> </div> </div> </#if> </#if> </@layout.registrationLayout>
Custom Template with Realm-Specific Branding
themes/lobbi-theme/login/template.ftl:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta name="robots" content="noindex, nofollow"> <meta name="viewport" content="width=device-width, initial-scale=1"> <#if properties.meta?has_content> <#list properties.meta?split(' ') as meta> <meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}"/> </#list> </#if> <title>${msg("loginTitle",(realm.displayName!''))}</title> <link rel="icon" href="${url.resourcesPath}/img/lobbi-icon.svg" /> <#if properties.stylesCommon?has_content> <#list properties.stylesCommon?split(' ') as style> <link href="${url.resourcesCommonPath}/${style}" rel="stylesheet" /> </#list> </#if> <#if properties.styles?has_content> <#list properties.styles?split(' ') as style> <link href="${url.resourcesPath}/${style}" rel="stylesheet" /> </#list> </#if> </head> <body class="keycloak-theme ${realm.name}-realm"> <div class="kc-container"> <div class="kc-content"> <div class="kc-brand"> <img src="${url.resourcesPath}/img/lobbi-logo.svg" alt="${realm.displayName!''}" /> </div> <div id="kc-header-wrapper"> <#nested "header"> </div> <div class="kc-form-wrapper"> <#nested "form"> </div> <#if displayInfo> <div id="kc-info-wrapper"> <#nested "info"> </div> </#if> </div> </div> </body> </html>
Realm-Specific CSS Variables
Lobbi Theme Variables
themes/lobbi-theme/login/resources/css/styles.css:
:root { /* Brand Colors */ --lobbi-primary: #0066cc; --lobbi-primary-dark: #0052a3; --lobbi-primary-light: #3384d6; --lobbi-secondary: #6c757d; --lobbi-accent: #17a2b8; /* Neutrals */ --lobbi-bg: #ffffff; --lobbi-surface: #f8f9fa; --lobbi-text: #212529; --lobbi-text-secondary: #6c757d; --lobbi-border: #dee2e6; /* Status Colors */ --lobbi-success: #28a745; --lobbi-error: #dc3545; --lobbi-warning: #ffc107; --lobbi-info: #17a2b8; /* Typography */ --lobbi-font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --lobbi-font-size-base: 16px; --lobbi-font-size-large: 18px; --lobbi-font-size-small: 14px; /* Spacing */ --lobbi-spacing-xs: 0.5rem; --lobbi-spacing-sm: 1rem; --lobbi-spacing-md: 1.5rem; --lobbi-spacing-lg: 2rem; --lobbi-spacing-xl: 3rem; /* Border Radius */ --lobbi-radius-sm: 4px; --lobbi-radius-md: 8px; --lobbi-radius-lg: 12px; /* Shadows */ --lobbi-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --lobbi-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); --lobbi-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); } body.keycloak-theme.thelobbi-realm { font-family: var(--lobbi-font-family); background: linear-gradient(135deg, var(--lobbi-primary-light) 0%, var(--lobbi-primary) 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; } .kc-container { width: 100%; max-width: 480px; padding: var(--lobbi-spacing-md); } .kc-content { background: var(--lobbi-bg); border-radius: var(--lobbi-radius-lg); box-shadow: var(--lobbi-shadow-lg); padding: var(--lobbi-spacing-xl); } .kc-brand { text-align: center; margin-bottom: var(--lobbi-spacing-lg); } .kc-brand img { height: 48px; width: auto; } /* Form Styles */ .kc-form-group { margin-bottom: var(--lobbi-spacing-md); } .kc-label { display: block; font-size: var(--lobbi-font-size-small); font-weight: 500; color: var(--lobbi-text); margin-bottom: var(--lobbi-spacing-xs); } .kc-input { width: 100%; padding: 0.75rem; font-size: var(--lobbi-font-size-base); border: 1px solid var(--lobbi-border); border-radius: var(--lobbi-radius-md); transition: border-color 0.2s, box-shadow 0.2s; } .kc-input:focus { outline: none; border-color: var(--lobbi-primary); box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); } .kc-input[aria-invalid="true"] { border-color: var(--lobbi-error); } /* Button Styles */ .kc-button { width: 100%; padding: 0.75rem; font-size: var(--lobbi-font-size-base); font-weight: 600; border: none; border-radius: var(--lobbi-radius-md); cursor: pointer; transition: background-color 0.2s, transform 0.1s; } .kc-button-primary { background-color: var(--lobbi-primary); color: white; } .kc-button-primary:hover { background-color: var(--lobbi-primary-dark); } .kc-button-primary:active { transform: translateY(1px); } /* Error Messages */ .kc-input-error-message { display: block; color: var(--lobbi-error); font-size: var(--lobbi-font-size-small); margin-top: var(--lobbi-spacing-xs); } /* Links */ a { color: var(--lobbi-primary); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--lobbi-primary-dark); text-decoration: underline; }
Brookside Theme Variables
themes/brookside-theme/login/resources/css/styles.css:
:root { /* Brand Colors - Brookside BI */ --brookside-primary: #2c5282; --brookside-primary-dark: #1e3a5f; --brookside-primary-light: #4a6fa5; --brookside-secondary: #718096; --brookside-accent: #805ad5; /* Data Visualization Colors */ --brookside-chart-1: #4299e1; --brookside-chart-2: #48bb78; --brookside-chart-3: #ed8936; --brookside-chart-4: #9f7aea; /* Neutrals */ --brookside-bg: #f7fafc; --brookside-surface: #ffffff; --brookside-text: #1a202c; --brookside-text-secondary: #718096; --brookside-border: #e2e8f0; /* Typography */ --brookside-font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } body.keycloak-theme.brooksidebi-realm { font-family: var(--brookside-font-family); background: linear-gradient(135deg, #1e3a5f 0%, #2c5282 50%, #4a6fa5 100%); } /* Apply Brookside-specific styling... */
JWT Claims and Theme Association
Client Configuration for Theme Claims
{ "clientId": "lobbi-web-app", "protocolMappers": [ { "name": "realm-name", "protocol": "openid-connect", "protocolMapper": "oidc-hardcoded-claim-mapper", "config": { "claim.name": "realm", "claim.value": "thelobbi", "jsonType.label": "String", "id.token.claim": "true", "access.token.claim": "true", "userinfo.token.claim": "true" } }, { "name": "theme-name", "protocol": "openid-connect", "protocolMapper": "oidc-hardcoded-claim-mapper", "config": { "claim.name": "theme", "claim.value": "lobbi-theme", "jsonType.label": "String", "id.token.claim": "true", "access.token.claim": "true" } } ] }
Accessing Theme in Application
// Example: Extract theme from JWT token interface TokenPayload { realm: string; theme: string; // ... other claims } function applyThemeFromToken(token: string) { const payload = JSON.parse(atob(token.split('.')[1])) as TokenPayload; // Apply theme dynamically if (payload.theme === 'lobbi-theme') { document.documentElement.setAttribute('data-theme', 'lobbi'); } else if (payload.theme === 'brookside-theme') { document.documentElement.setAttribute('data-theme', 'brookside'); } }
Environment Variables Setup
Keycloak Configuration
.env.local:
# Keycloak Base URL NEXT_PUBLIC_KEYCLOAK_URL=https://auth.thelobbi.com KEYCLOAK_URL=https://auth.thelobbi.com # Lobbi Realm NEXT_PUBLIC_KEYCLOAK_REALM_LOBBI=thelobbi NEXT_PUBLIC_KEYCLOAK_CLIENT_ID_LOBBI=lobbi-web-app KEYCLOAK_CLIENT_SECRET_LOBBI=your-client-secret-here # Brookside Realm NEXT_PUBLIC_KEYCLOAK_REALM_BROOKSIDE=brooksidebi NEXT_PUBLIC_KEYCLOAK_CLIENT_ID_BROOKSIDE=brookside-web-app KEYCLOAK_CLIENT_SECRET_BROOKSIDE=your-client-secret-here # PKCE Configuration NEXT_PUBLIC_KEYCLOAK_PKCE_ENABLED=true NEXT_PUBLIC_KEYCLOAK_RESPONSE_TYPE=code NEXT_PUBLIC_KEYCLOAK_SCOPE=openid profile email # Redirect URIs NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI_LOBBI=https://app.thelobbi.com/auth/callback NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI_BROOKSIDE=https://bi.brooksideadvisory.com/auth/callback
Next.js Authentication Configuration
lib/keycloak.ts:
import Keycloak from 'keycloak-js'; interface KeycloakConfig { realm: string; clientId: string; url: string; } export function getKeycloakConfig(tenant: 'lobbi' | 'brookside'): KeycloakConfig { if (tenant === 'lobbi') { return { realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM_LOBBI!, clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID_LOBBI!, url: process.env.NEXT_PUBLIC_KEYCLOAK_URL!, }; } else { return { realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM_BROOKSIDE!, clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID_BROOKSIDE!, url: process.env.NEXT_PUBLIC_KEYCLOAK_URL!, }; } } export function initKeycloak(tenant: 'lobbi' | 'brookside') { const config = getKeycloakConfig(tenant); const keycloak = new Keycloak({ url: config.url, realm: config.realm, clientId: config.clientId, }); return keycloak.init({ onLoad: 'login-required', checkLoginIframe: false, pkceMethod: 'S256', // PKCE enabled }); }
Docker Deployment
Dockerfile for Custom Themes
FROM quay.io/keycloak/keycloak:23.0 # Copy custom themes COPY themes/lobbi-theme /opt/keycloak/themes/lobbi-theme COPY themes/brookside-theme /opt/keycloak/themes/brookside-theme # Set environment variables ENV KC_DB=postgres ENV KC_HEALTH_ENABLED=true ENV KC_METRICS_ENABLED=true # Production build RUN /opt/keycloak/bin/kc.sh build ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
Docker Compose
version: '3.8' services: keycloak: build: . environment: KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: ${KC_DB_PASSWORD} KC_HOSTNAME: auth.thelobbi.com KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} ports: - "8080:8080" depends_on: - postgres command: start --optimized postgres: image: postgres:15 environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: ${KC_DB_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:
Testing Themes
Theme Development Workflow
- Local Development:
# Start Keycloak with theme watching docker run -p 8080:8080 \ -v $(pwd)/themes:/opt/keycloak/themes \ -e KEYCLOAK_ADMIN=admin \ -e KEYCLOAK_ADMIN_PASSWORD=admin \ quay.io/keycloak/keycloak:23.0 \ start-dev
- Clear Theme Cache:
# In Keycloak admin console Realm Settings > Themes > Clear Cache
- Test Different Realms:
# Test Lobbi realm http://localhost:8080/realms/thelobbi/account # Test Brookside realm http://localhost:8080/realms/brooksidebi/account
Best Practices
- Consistency: Maintain consistent branding across login, account, and email themes
- Accessibility: Ensure WCAG AA compliance for all theme elements
- Performance: Optimize images and CSS for fast loading
- Responsive: Test themes on mobile, tablet, and desktop
- Security: Never expose sensitive configuration in theme files
- Version Control: Track theme changes with git
- Documentation: Document realm-specific customizations
Integration with Other Skills
- design-styles: Apply design system styles to Keycloak themes
- css-generation: Generate theme CSS from design tokens
- component-patterns: Use consistent components across app and auth pages
Resources
- Keycloak Themes Documentation
- FreeMarker Template Guide
- PKCE Flow Specification
- Theme Examples:
[[Resources/Keycloak/Theme-Examples]]