Skip to content

Add theme upload and management via admin REST API#50121

Open
vikas0686 wants to merge 4 commits into
keycloak:mainfrom
vikas0686:f/vikas/keycloak/admin/theme
Open

Add theme upload and management via admin REST API#50121
vikas0686 wants to merge 4 commits into
keycloak:mainfrom
vikas0686:f/vikas/keycloak/admin/theme

Conversation

@vikas0686

@vikas0686 vikas0686 commented Jun 18, 2026

Copy link
Copy Markdown

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 all
custom 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 ThemeUploadResource sub-resource is registered on RealmAdminResource
at the /themes path. All three operations require the manage-realm realm
role.

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 QuarkusFolderThemeProviderFactory where the themes
directory 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 to FolderThemeProvider so
ThemeUploadResource can 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 and
never updated. A useRefreshServerInfo() hook was added that triggers a
background 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() hook
is unchanged so no current callers are affected.

New i18n keys were added to messages_en.properties for all user-facing
strings 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 a themes/ subfolder by default) or by
passing --spi-theme-folder-dir explicitly.

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.properties file declaring
the 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-data request to /admin/realms/{realm}/themes.

Listing uploaded themes

Sending a GET request to /admin/realms/{realm}/themes returns a JSON array
of 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 ThemeUploadResourceTest covering all three
endpoints across 15 test cases. Tests that require a writable themes directory
skip automatically when the server is not started with kc.home.dir, so the
suite 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
image

image image

Allows administrators to upload, list, and delete custom themes
through the admin console without requiring server restarts or
direct filesystem access.
@vikas0686 vikas0686 requested a review from a team as a code owner June 18, 2026 11:56
Copilot AI review requested due to automatic review settings June 18, 2026 11:56
@vikas0686 vikas0686 requested review from a team as code owners June 18, 2026 11:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}/themes sub-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.

Copilot AI review requested due to automatic review settings June 18, 2026 12:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Comment on lines +160 to +163
File themesDir = getThemesDir();
if (themesDir == null || !themesDir.isDirectory()) {
throw new NotFoundException("Theme directory not configured");
}
Comment on lines +176 to +180
try {
deleteDirectory(themeDir.toPath());
} catch (IOException e) {
throw new InternalServerErrorException("Failed to delete theme: " + e.getMessage());
}
Comment on lines +243 to +245
/**
* Base path for uploading custom themes.
*/
Comment on lines +62 to +81
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();
}, []);
Comment on lines +53 to +65
/**
* 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 {

Comment on lines +249 to +265
/**
* 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>
Copilot AI review requested due to automatic review settings June 18, 2026 13:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comment on lines +120 to +143
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.");
}
Comment on lines +165 to +169
// 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");
}
Comment on lines +232 to +237
if (entry.isDirectory()) {
Files.createDirectories(entryPath);
} else {
Files.createDirectories(entryPath.getParent());
Files.copy(zipStream, entryPath, StandardCopyOption.REPLACE_EXISTING);
}
Comment on lines +241 to +245
if (themeFolderName == null) {
throw new BadRequestException(
"Invalid theme archive: no valid theme entries found after skipping metadata.");
}
}
Comment on lines +53 to +58
/**
* 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 michalvavrik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@thePier

thePier commented Jun 18, 2026

Copy link
Copy Markdown

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

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants