Add theme upload and management via admin REST API#50121
Conversation
Allows administrators to upload, list, and delete custom themes through the admin console without requiring server restarts or direct filesystem access.
There was a problem hiding this comment.
Pull request overview
Adds end-to-end custom theme lifecycle management (upload/list/delete) via the Admin REST API and wires it into the Admin Console UI so uploaded themes become selectable without a server restart.
Changes:
- Introduces
/admin/realms/{realm}/themessub-resource for uploading theme ZIPs, listing folder-based themes, and deleting themes (with realm theme-setting auto-reset + cache clear). - Updates Quarkus folder theme provider initialization to allow non-existent theme directories (created on first upload), and exposes the folder themes directory via
FolderThemeProvider#getThemesDir(). - Extends Admin UI theme settings to upload/delete themes and refresh server-info on demand; adds i18n strings and integration tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/base/src/test/java/org/keycloak/tests/admin/ThemeUploadResourceTest.java | New integration tests for upload behavior and security/error cases. |
| services/src/main/java/org/keycloak/theme/FolderThemeProvider.java | Adds accessor for the configured folder themes directory. |
| services/src/main/java/org/keycloak/services/resources/admin/ThemeUploadResource.java | New admin REST sub-resource implementing list/upload/delete for custom themes. |
| services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java | Registers the new themes admin sub-resource under realm admin. |
| quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/themes/QuarkusFolderThemeProviderFactory.java | Stops requiring the themes dir to exist at startup (supports create-on-upload). |
| js/apps/admin-ui/src/realm-settings/themes/ThemeSettings.tsx | Adds upload + custom-themes management UI, calls new endpoints, refreshes server info. |
| js/apps/admin-ui/src/context/server-info/ServerInfoProvider.tsx | Adds refresh capability to re-fetch server info on demand. |
| js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties | Adds i18n keys for upload/delete theme flows. |
| File themesDir = getThemesDir(); | ||
| if (themesDir == null || !themesDir.isDirectory()) { | ||
| throw new NotFoundException("Theme directory not configured"); | ||
| } |
| try { | ||
| deleteDirectory(themeDir.toPath()); | ||
| } catch (IOException e) { | ||
| throw new InternalServerErrorException("Failed to delete theme: " + e.getMessage()); | ||
| } |
| /** | ||
| * Base path for uploading custom themes. | ||
| */ |
| const loadCustomThemes = async () => { | ||
| try { | ||
| const response = await fetchWithError( | ||
| joinPath(adminClient.baseUrl, `admin/realms/${realmName}/themes`), | ||
| { | ||
| headers: { | ||
| ...getAuthorizationHeaders(await adminClient.getAccessToken()), | ||
| }, | ||
| }, | ||
| ); | ||
| setCustomThemes(await response.json()); | ||
| } catch { | ||
| setCustomThemes([]); | ||
| } | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| setupForm(); | ||
| void loadCustomThemes(); | ||
| }, []); |
| /** | ||
| * Integration tests for {@code POST /admin/realms/{realm}/themes} (ThemeUploadResource). | ||
| * | ||
| * Tests that require a configured themes directory (kc.home.dir) are skipped automatically | ||
| * when the server is not started with that option. | ||
| * | ||
| * To run the full suite locally: | ||
| * ../mvnw -f server/pom.xml compile quarkus:dev -Dkc.config.built=true \ | ||
| * -Dquarkus.args="start-dev" -Dkc.home.dir=.kc | ||
| */ | ||
| @KeycloakIntegrationTest | ||
| public class ThemeUploadResourceTest { | ||
|
|
| /** | ||
| * Skips the test if the server's FolderThemeProvider has no configured themes directory | ||
| * (i.e. the server was not started with kc.home.dir). | ||
| */ | ||
| private void assumeThemesDirConfigured() { | ||
| String path = runOnServer.fetchString(session -> { | ||
| for (ThemeProvider p : session.getAllProviders(ThemeProvider.class)) { | ||
| if (p instanceof FolderThemeProvider folder) { | ||
| File dir = folder.getThemesDir(); | ||
| return dir != null ? dir.getAbsolutePath() : ""; | ||
| } | ||
| } | ||
| return ""; | ||
| }); | ||
| assumeTrue(path != null && !path.isEmpty(), | ||
| "Skipped: server not started with kc.home.dir — themes directory unavailable"); | ||
| } |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Vikas Pandey <144092552+vikas0686@users.noreply.github.com>
| MultivaluedMap<String, FormPartValue> formDataMap = | ||
| session.getContext().getHttpRequest().getMultiPartFormParameters(); | ||
| if (!formDataMap.containsKey("file")) { | ||
| throw new BadRequestException("No file provided"); | ||
| } | ||
|
|
||
| File themesDir = getThemesDir(); | ||
| if (themesDir == null) { | ||
| throw new BadRequestException( | ||
| "Theme directory is not configured. Start the server with --spi-theme-folder-dir=<path> or set kc.home.dir."); | ||
| } | ||
| if (!themesDir.isDirectory() && !themesDir.mkdirs()) { | ||
| throw new BadRequestException( | ||
| "Cannot create theme directory: " + themesDir.getAbsolutePath()); | ||
| } | ||
|
|
||
| try (InputStream inputStream = formDataMap.getFirst("file").asInputStream(); | ||
| ZipInputStream zipStream = new ZipInputStream(inputStream)) { | ||
| extractZip(zipStream, themesDir.toPath()); | ||
| } catch (BadRequestException e) { | ||
| throw e; | ||
| } catch (IOException e) { | ||
| throw new BadRequestException("Failed to process theme archive."); | ||
| } |
| // Resolve and normalise to prevent path traversal | ||
| Path resolved = themesDir.toPath().resolve(themeName).normalize(); | ||
| if (!resolved.startsWith(themesDir.toPath())) { | ||
| throw new BadRequestException("Invalid theme name"); | ||
| } |
| if (entry.isDirectory()) { | ||
| Files.createDirectories(entryPath); | ||
| } else { | ||
| Files.createDirectories(entryPath.getParent()); | ||
| Files.copy(zipStream, entryPath, StandardCopyOption.REPLACE_EXISTING); | ||
| } |
| if (themeFolderName == null) { | ||
| throw new BadRequestException( | ||
| "Invalid theme archive: no valid theme entries found after skipping metadata."); | ||
| } | ||
| } |
| /** | ||
| * Integration tests for {@code POST /admin/realms/{realm}/themes} (ThemeUploadResource). | ||
| * | ||
| * Tests that require a configured themes directory (kc.home.dir) are skipped automatically | ||
| * when the server is not started with that option. | ||
| * |
michalvavrik
left a comment
There was a problem hiding this comment.
Hello @vikas0686 , this PR is currently not linked with any GitHub issue. Please have a look at https://github.com/keycloak/keycloak/blob/main/CONTRIBUTING.md#contributing-to-keycloak , there is a checklist I believe this PR didn't follow. Thanks
How would you resolve installations made with docker: still mapping the themes folder on the host machine to make them permanent? Otherwise at the first restart or down/up of the container you'll lose everything |
Problem
Keycloak supports custom themes for Login, Account, Admin, and Email pages,
but the current workflow requires administrators to gain direct filesystem
access to the server host, manually copy theme folders into the server's
themes directory, and restart the server or flush the theme cache. This makes
theme management impossible in cloud and containerised deployments where the
filesystem is ephemeral or locked down. It also blocks non-technical
administrators from iterating on UI customisations without involving
infrastructure teams.
There is currently no API or admin-console workflow to upload, list, or remove
themes at runtime.
Solution
This PR adds a complete theme management lifecycle through the Keycloak Admin
REST API and the Admin Console UI. Three new operations are introduced under
/admin/realms/{realm}/themes: uploading a theme ZIP archive, listing allcustom uploaded themes, and deleting a custom theme by name. Themes are
extracted into the server's configured folder-theme directory and become
available immediately — no server restart required.
Implementation
Backend
A new
ThemeUploadResourcesub-resource is registered onRealmAdminResourceat the
/themespath. All three operations require themanage-realmrealmrole.
Upload
Reads a multipart/form-data request with a file field containing the theme
ZIP. The archive is validated and extracted in a single streaming pass. After
extraction, session.theme().clearCache() is called so the new theme is
visible in dropdowns immediately.
The extractor enforces the documented one-top-level-folder contract during the
same pass — no separate pre-scan or buffering is needed. Known OS-generated
metadata entries produced by macOS Finder (__MACOSX/, .DS_Store) and
Windows Explorer (Thumbs.db) are silently skipped. Any other stray top-level
file, or a second top-level folder in the same archive, causes the request to
be rejected with 400 Bad Request and no bytes are left on disk.
How to use → Uploading a theme (updated)
A theme ZIP must contain exactly one top-level folder named after the theme,
with subdirectories for each theme type it supports. Each subdirectory must
contain at minimum a theme.properties file declaring the parent theme.
my-brand-theme.zip
└── my-brand-theme/
├── login/
│ └── theme.properties
├── account/
│ └── theme.properties
├── admin/
│ └── theme.properties
└── email/
└── theme.properties
The server enforces this structure. Uploads are rejected with 400 Bad Request
if the archive contains stray top-level files or more than one top-level
folder. Known OS metadata files (.DS_Store, __MACOSX/, Thumbs.db)
embedded by macOS and Windows are silently skipped and do not affect
extraction.
In the Admin Console, navigate to Realm Settings → Themes, click
Upload theme, and select the ZIP file. The theme becomes selectable in
the dropdowns immediately.
The same operation is available via the Admin REST API by posting the ZIP as
a multipart/form-data request to /admin/realms/{realm}/themes.
List scans the themes directory and returns only folder-based
(user-uploaded) themes — built-in themes bundled in JARs are never included.
Each entry in the response carries the theme name and the list of theme types
it supports (login, account, admin, email, etc.).
Delete removes the theme directory recursively. As a safety measure, if
the realm had any theme setting pointing to the deleted theme, those fields are
automatically reset to null so the realm falls back to the Keycloak default
rather than showing a broken-theme error on the next page load.
All ZIP uploads are protected against zip-slip path traversal attacks. Every
entry path is resolved and normalised before any bytes are written to disk, and
any entry that would escape the target directory is rejected with a 400 response.
A bug was also fixed in
QuarkusFolderThemeProviderFactorywhere the themesdirectory path was discarded if the directory did not already exist at server
startup. This caused every first-ever upload to return 400 because the provider
had no path to extract into. The directory is now created on demand during the
first upload.
A
getThemesDir()accessor was added toFolderThemeProvidersoThemeUploadResourcecan resolve the themes path without reflective access.Frontend
Theme Settings tab gains an "Upload theme" button in the action group. On
file selection, the ZIP is posted to the backend and, on success, both the
dropdown option lists and the custom-themes section update in place — no full
page reload.
Below the existing theme dropdowns, a new Custom themes section appears
whenever at least one uploaded theme exists. Each row shows the theme name,
coloured badges for each supported type (login, account, admin, email), and a
trash icon. Clicking the trash icon opens a confirmation dialog before the
delete request is sent.
Server-info refresh — the theme dropdowns source their option lists from
useServerInfo().themes, which was previously fetched once at app startup andnever updated. A
useRefreshServerInfo()hook was added that triggers abackground re-fetch of server info after upload or delete. Because the fetch
does not reset the cached state between calls, the dropdowns update seamlessly
with no loading spinner or page flash. The existing
useServerInfo()hookis unchanged so no current callers are affected.
New i18n keys were added to
messages_en.propertiesfor all user-facingstrings in the upload and delete flows.
How to use
Prerequisites
The server must be started with a writable themes directory, either by
setting
kc.home.dir(which uses athemes/subfolder by default) or bypassing
--spi-theme-folder-direxplicitly.Uploading a theme
A theme ZIP must contain a top-level folder named after the theme, with
subdirectories for each theme type it supports (login, account, admin, email).
Each subdirectory must contain at minimum a
theme.propertiesfile declaringthe parent theme.
In the Admin Console, navigate to Realm Settings → Themes, click
Upload theme, and select the ZIP file. The theme becomes selectable in
the dropdowns immediately.
The same operation is available via the Admin REST API by posting the ZIP as
a
multipart/form-datarequest to/admin/realms/{realm}/themes.Listing uploaded themes
Sending a GET request to
/admin/realms/{realm}/themesreturns a JSON arrayof all custom themes, each with its name and the list of supported theme types.
Built-in Keycloak themes are not included.
Deleting a theme
In the Admin Console, scroll to the Custom themes section on the Themes
tab, click the trash icon next to the theme, and confirm the dialog.
The same operation is available via the Admin REST API by sending a DELETE
request to
/admin/realms/{realm}/themes/{themeName}.If the realm was actively using the deleted theme for any type, the
corresponding realm setting is automatically cleared so the realm falls back
to the default theme without administrator intervention.
Testing
Integration tests are provided in
ThemeUploadResourceTestcovering all threeendpoints across 15 test cases. Tests that require a writable themes directory
skip automatically when the server is not started with
kc.home.dir, so thesuite can always be compiled and partially run without additional setup.
Coverage includes successful upload, list, and delete operations; error cases
(missing file part, non-ZIP content, non-existent theme); authentication checks
(unauthenticated requests return 401 for all three endpoints); the zip-slip
path traversal attack; the built-in-themes exclusion from the list; and the
realm setting auto-reset behaviour when an active theme is deleted.
##UI
