diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index 48c9f0b56fcd..629a601f8e20 100644 --- a/.github/workflows/js-ci.yml +++ b/.github/workflows/js-ci.yml @@ -182,8 +182,6 @@ jobs: - name: Run Playwright tests run: pnpm --fail-if-no-match --filter ${{ env.WORKSPACE }} test - env: - KEYCLOAK_SERVER: http://localhost:8080 - name: Upload Playwright report uses: actions/upload-artifact@v4 @@ -285,7 +283,6 @@ jobs: working-directory: js/apps/admin-ui env: CYPRESS_BASE_URL: http://localhost:8080/admin/ - CYPRESS_KEYCLOAK_SERVER: http://localhost:8080 SPLIT: ${{ strategy.job-total }} SPLIT_INDEX: ${{ strategy.job-index }} SPLIT_RANDOM_SEED: ${{ needs.generate-test-seed.outputs.seed }} diff --git a/common/src/main/java/org/keycloak/common/util/Environment.java b/common/src/main/java/org/keycloak/common/util/Environment.java index 02aa6da79d00..f491eed5d305 100644 --- a/common/src/main/java/org/keycloak/common/util/Environment.java +++ b/common/src/main/java/org/keycloak/common/util/Environment.java @@ -29,6 +29,10 @@ public class Environment { public static final int DEFAULT_JBOSS_AS_STARTUP_TIMEOUT = 300; + public static final String PROFILE = "kc.profile"; + public static final String ENV_PROFILE = "KC_PROFILE"; + public static final String DEV_PROFILE_VALUE = "dev"; + public static int getServerStartupTimeout() { String timeout = System.getProperty("jboss.as.management.blocking.timeout"); if (timeout != null) { @@ -57,4 +61,17 @@ public static boolean isJavaInFipsMode() { return false; } + public static boolean isDevMode() { + return DEV_PROFILE_VALUE.equalsIgnoreCase(getProfile()); + } + + public static String getProfile() { + String profile = System.getProperty(PROFILE); + + if (profile != null) { + return profile; + } + + return System.getenv(ENV_PROFILE); + } } diff --git a/core/pom.xml b/core/pom.xml index c057066ccc49..5d1f8600b634 100755 --- a/core/pom.xml +++ b/core/pom.xml @@ -54,6 +54,10 @@ com.fasterxml.jackson.core jackson-databind + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + org.jboss.logging jboss-logging diff --git a/core/src/main/java/org/keycloak/util/JsonSerialization.java b/core/src/main/java/org/keycloak/util/JsonSerialization.java index e321d11151f5..e082ac54ba20 100755 --- a/core/src/main/java/org/keycloak/util/JsonSerialization.java +++ b/core/src/main/java/org/keycloak/util/JsonSerialization.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import java.io.IOException; import java.io.InputStream; @@ -41,6 +42,7 @@ public class JsonSerialization { public static final ObjectMapper sysPropertiesAwareMapper = new ObjectMapper(new SystemPropertiesJsonParserFactory()); static { + mapper.registerModule(new Jdk8Module()); mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); prettyMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); diff --git a/js/apps/account-ui/index.html b/js/apps/account-ui/index.html deleted file mode 100644 index f284617802de..000000000000 --- a/js/apps/account-ui/index.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - Account Management - - - -
-
-
- - - - - -
-

Loading the account console

-
-
-
-
- - - - - diff --git a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/index.ftl b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/index.ftl new file mode 100644 index 000000000000..1cc5a4d4e9cb --- /dev/null +++ b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/index.ftl @@ -0,0 +1,139 @@ + + + + + + + + + ${properties.title!'Account Management'} + + + <#if devServerUrl?has_content> + + + + + + <#if entryStyles?has_content> + <#list entryStyles as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + <#if entryScript?has_content> + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if entryImports?has_content> + <#list entryImports as import> + + + + + +
+
+
+ + + + + +
+

Loading the Account Console

+
+
+
+
+ + + + diff --git a/js/apps/account-ui/playwright.config.ts b/js/apps/account-ui/playwright.config.ts index 706eeb49dd25..8580ab472adb 100644 --- a/js/apps/account-ui/playwright.config.ts +++ b/js/apps/account-ui/playwright.config.ts @@ -1,5 +1,6 @@ import { defineConfig, devices } from "@playwright/test"; -import { getRootPath } from "./src/utils/getRootPath"; + +import { getAccountUrl } from "./test/utils"; /** * See https://playwright.dev/docs/test-configuration. @@ -16,7 +17,7 @@ export default defineConfig({ }, use: { - baseURL: `http://localhost:8080${getRootPath()}`, + baseURL: getAccountUrl(), trace: "on-first-retry", }, diff --git a/js/apps/account-ui/pom.xml b/js/apps/account-ui/pom.xml index 9378e6035a6a..203fad0206fc 100644 --- a/js/apps/account-ui/pom.xml +++ b/js/apps/account-ui/pom.xml @@ -94,110 +94,6 @@ - - com.google.code.maven-replacer-plugin - maven-replacer-plugin - - - process-resources - - replace - - - - - dist/index.html - target/classes/theme/keycloak.v3/account/index.ftl - false - - - src="./ - src="${resourceUrl}/ - - - href="./ - href="${resourceUrl}/ - - - ]]> - ]]> - - - ]]> - ]]> - - - Account Management]]> - -${properties.title!"Account Management"} - -]]> - - - ]]> - - - { - "authUrl": "${authUrl}", - "authServerUrl": "${authServerUrl}", - "realm": "${realm.name}", - "clientId": "${clientId}", - "resourceUrl": "${resourceUrl}", - "logo": "${properties.logo!""}", - "logoUrl": "${properties.logoUrl!""}", - "baseUrl": "${baseUrl}", - "locale": "${locale}", - "referrerName": "${referrerName!""}", - "referrerUrl": "${referrer_uri!""}", - "features": { - "isRegistrationEmailAsUsername": ${realm.registrationEmailAsUsername?c}, - "isEditUserNameAllowed": ${realm.editUsernameAllowed?c}, - "isInternationalizationEnabled": ${realm.isInternationalizationEnabled()?c}, - "isLinkedAccountsEnabled": ${realm.identityFederationEnabled?c}, - "isMyResourcesEnabled": ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c}, - "deleteAccountAllowed": ${deleteAccountAllowed?c}, - "updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c}, - "updateEmailActionEnabled": ${updateEmailActionEnabled?c}, - "isViewGroupsEnabled": ${isViewGroupsEnabled?c}, - "isOid4VciEnabled": ${isOid4VciEnabled?c} - } - } - - -]]> - - - - ]]> - - - <#list properties.scripts?split(' ') as script> - - - - <#if properties.styles?has_content> - <#list properties.styles?split(' ') as style> - - - - -]]> - - - - - \ No newline at end of file diff --git a/js/apps/account-ui/src/utils/getRootPath.ts b/js/apps/account-ui/src/utils/getRootPath.ts deleted file mode 100644 index 819df7055d8d..000000000000 --- a/js/apps/account-ui/src/utils/getRootPath.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { generatePath } from "react-router-dom"; -import { DEFAULT_REALM, ROOT_PATH } from "../constants"; - -export const getRootPath = (realm = DEFAULT_REALM) => - generatePath(ROOT_PATH, { realm }); diff --git a/js/apps/account-ui/test/account-security/linked-accounts.spec.ts b/js/apps/account-ui/test/account-security/linked-accounts.spec.ts index 5d8eca7bafab..a6fdfe3dade3 100644 --- a/js/apps/account-ui/test/account-security/linked-accounts.spec.ts +++ b/js/apps/account-ui/test/account-security/linked-accounts.spec.ts @@ -13,8 +13,8 @@ import { findClientByClientId, inRealm, } from "../admin-client"; +import { SERVER_URL } from "../constants"; import groupsIdPClient from "../realms/groups-idp.json" assert { type: "json" }; -import { getKeycloakServerUrl } from "../utils"; const realm = "groups"; @@ -32,7 +32,6 @@ test.describe("Account linking", () => { groupIdPClientId = await createClient( groupsIdPClient as ClientRepresentation, ); - const baseUrl = getKeycloakServerUrl(); const idp: IdentityProviderRepresentation = { alias: "master-idp", providerId: "oidc", @@ -41,12 +40,12 @@ test.describe("Account linking", () => { clientId: "groups-idp", clientSecret: "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw", validateSignature: "false", - tokenUrl: `${baseUrl}/realms/master/protocol/openid-connect/token`, - jwksUrl: `${baseUrl}/realms/master/protocol/openid-connect/certs`, - issuer: `${baseUrl}/realms/master`, - authorizationUrl: `${baseUrl}/realms/master/protocol/openid-connect/auth`, - logoutUrl: `${baseUrl}/realms/master/protocol/openid-connect/logout`, - userInfoUrl: `${baseUrl}/realms/master/protocol/openid-connect/userinfo`, + tokenUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/token`, + jwksUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/certs`, + issuer: `${SERVER_URL}/realms/master`, + authorizationUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/auth`, + logoutUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/logout`, + userInfoUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/userinfo`, }, }; diff --git a/js/apps/account-ui/test/admin-client.ts b/js/apps/account-ui/test/admin-client.ts index 7824a0532e52..30a1f2628bef 100644 --- a/js/apps/account-ui/test/admin-client.ts +++ b/js/apps/account-ui/test/admin-client.ts @@ -5,11 +5,10 @@ import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmR import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; -import { DEFAULT_REALM } from "../src/constants"; -import { getKeycloakServerUrl } from "./utils"; +import { DEFAULT_REALM, SERVER_URL } from "./constants"; const adminClient = new KeycloakAdminClient({ - baseUrl: getKeycloakServerUrl(), + baseUrl: SERVER_URL, realmName: DEFAULT_REALM, }); diff --git a/js/apps/account-ui/test/applications.spec.ts b/js/apps/account-ui/test/applications.spec.ts index dee8bb9bfda5..d509e6de1427 100644 --- a/js/apps/account-ui/test/applications.spec.ts +++ b/js/apps/account-ui/test/applications.spec.ts @@ -1,12 +1,11 @@ import { expect, test } from "@playwright/test"; -import { getRootPath } from "../src/utils/getRootPath"; import { login } from "./login"; -import { getAccountUrl, getAdminUrl } from "./utils"; +import { getAccountUrl, getAdminUrl, getRootPath } from "./utils"; test.describe("Applications test", () => { test.beforeEach(async ({ page }) => { // Sign out all devices before each test - await login(page, "admin", "admin"); + await login(page); await page.getByTestId("accountSecurity").click(); await page.getByTestId("account-security/device-activity").click(); @@ -21,13 +20,13 @@ test.describe("Applications test", () => { }); test("Single application", async ({ page }) => { - await login(page, "admin", "admin"); + await login(page); await page.getByTestId("applications").click(); await expect(page.getByTestId("applications-list-item")).toHaveCount(1); await expect(page.getByTestId("applications-list-item")).toContainText( - process.env.CI ? "Account Console" : "security-admin-console-v2", + "Account Console", ); }); @@ -41,17 +40,15 @@ test.describe("Applications test", () => { const page1 = await context1.newPage(); const page2 = await context2.newPage(); - await login(page1, "admin", "admin"); - await login(page2, "admin", "admin"); + await login(page1); + await login(page2); await page1.getByTestId("applications").click(); await expect(page1.getByTestId("applications-list-item")).toHaveCount(1); await expect( page1.getByTestId("applications-list-item").nth(0), - ).toContainText( - process.env.CI ? "Account Console" : "security-admin-console-v2", - ); + ).toContainText("Account Console"); } finally { await context1.close(); await context2.close(); @@ -59,12 +56,7 @@ test.describe("Applications test", () => { }); test("Two applications", async ({ page }) => { - test.skip( - !process.env.CI, - "Skip this test if not running with regular Keycloak", - ); - - await login(page, "admin", "admin"); + await login(page); // go to admin console await page.goto("/"); diff --git a/js/apps/account-ui/src/constants.ts b/js/apps/account-ui/test/constants.ts similarity index 76% rename from js/apps/account-ui/src/constants.ts rename to js/apps/account-ui/test/constants.ts index fbbb6766940b..9f9d3a85da77 100644 --- a/js/apps/account-ui/src/constants.ts +++ b/js/apps/account-ui/test/constants.ts @@ -1,4 +1,5 @@ -export const DEFAULT_REALM = "master"; +export const SERVER_URL = "http://localhost:8080"; export const ROOT_PATH = "/realms/:realm/account"; +export const DEFAULT_REALM = "master"; export const ADMIN_USER = "admin"; export const ADMIN_PASSWORD = "admin"; diff --git a/js/apps/account-ui/test/login.ts b/js/apps/account-ui/test/login.ts index 43ec188ee691..893c7cc96733 100644 --- a/js/apps/account-ui/test/login.ts +++ b/js/apps/account-ui/test/login.ts @@ -1,11 +1,12 @@ import { Page } from "@playwright/test"; -import { DEFAULT_REALM } from "../src/constants"; -import { getRootPath } from "../src/utils/getRootPath"; + +import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "./constants"; +import { getRootPath } from "./utils"; export const login = async ( page: Page, - username: string, - password: string, + username = ADMIN_USER, + password = ADMIN_PASSWORD, realm = DEFAULT_REALM, queryParams?: Record, ) => { diff --git a/js/apps/account-ui/test/referrer.spec.ts b/js/apps/account-ui/test/referrer.spec.ts index d99560497ace..74db8b0ef330 100644 --- a/js/apps/account-ui/test/referrer.spec.ts +++ b/js/apps/account-ui/test/referrer.spec.ts @@ -1,10 +1,8 @@ import { expect, test } from "@playwright/test"; + +import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "./constants"; import { login } from "./login"; import { getAdminUrl } from "./utils"; -import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "../src/constants"; - -// NOTE: This test suite will only pass when running a production build, as the referrer is extracted on the server side. -// This will change once https://github.com/keycloak/keycloak/pull/27311 has been merged. test.describe("Signing in with referrer link", () => { test("shows a referrer link when a matching client exists", async ({ diff --git a/js/apps/account-ui/test/utils.ts b/js/apps/account-ui/test/utils.ts index f7aebfbba2cc..efdb06e47fe8 100644 --- a/js/apps/account-ui/test/utils.ts +++ b/js/apps/account-ui/test/utils.ts @@ -1,18 +1,14 @@ -import { getRootPath } from "../src/utils/getRootPath"; +import { generatePath } from "react-router-dom"; -function getTestServerUrl(): string { - return process.env.KEYCLOAK_SERVER ?? "http://localhost:8080"; -} - -export function getKeycloakServerUrl(): string { - // In CI, the Keycloak server is running in the same server as tested console, while in dev, it is running on a different port - return process.env.CI ? getTestServerUrl() : "http://localhost:8180"; -} +import { DEFAULT_REALM, ROOT_PATH, SERVER_URL } from "./constants"; export function getAccountUrl() { - return getTestServerUrl() + getRootPath(); + return SERVER_URL + getRootPath(); } export function getAdminUrl() { - return getKeycloakServerUrl() + "/admin/master/console/"; + return SERVER_URL + "/admin/master/console/"; } + +export const getRootPath = (realm = DEFAULT_REALM) => + generatePath(ROOT_PATH, { realm }); diff --git a/js/apps/account-ui/vite.config.ts b/js/apps/account-ui/vite.config.ts index 718b494da68e..51ea70159974 100644 --- a/js/apps/account-ui/vite.config.ts +++ b/js/apps/account-ui/vite.config.ts @@ -4,8 +4,6 @@ import { defineConfig, loadEnv } from "vite"; import { checker } from "vite-plugin-checker"; import dts from "vite-plugin-dts"; -import { getRootPath } from "./src/utils/getRootPath"; - // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); @@ -28,8 +26,8 @@ export default defineConfig(({ mode }) => { return { base: "", server: { - port: 8080, - open: getRootPath(), + origin: "http://localhost:5173", + port: 5173, }, build: { ...lib, @@ -37,7 +35,9 @@ export default defineConfig(({ mode }) => { target: "esnext", modulePreload: false, cssMinify: "lightningcss", + manifest: true, rollupOptions: { + input: "src/main.tsx", external, }, }, diff --git a/js/apps/admin-ui/cypress/e2e/identity_providers_oidc_test.spec.ts b/js/apps/admin-ui/cypress/e2e/identity_providers_oidc_test.spec.ts index 2df1f7a86c38..a593a96a1036 100644 --- a/js/apps/admin-ui/cypress/e2e/identity_providers_oidc_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/identity_providers_oidc_test.spec.ts @@ -1,17 +1,18 @@ -import Masthead from "../support/pages/admin-ui/Masthead"; -import SidebarPage from "../support/pages/admin-ui/SidebarPage"; +import { SERVER_URL } from "../support/constants"; import LoginPage from "../support/pages/LoginPage"; -import { keycloakBefore } from "../support/util/keycloak_hooks"; import ListingPage from "../support/pages/admin-ui/ListingPage"; -import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage"; -import ModalUtils from "../support/util/ModalUtils"; +import Masthead from "../support/pages/admin-ui/Masthead"; +import SidebarPage from "../support/pages/admin-ui/SidebarPage"; import AddMapperPage from "../support/pages/admin-ui/manage/identity_providers/AddMapperPage"; -import ProviderBaseGeneralSettingsPage from "../support/pages/admin-ui/manage/identity_providers/ProviderBaseGeneralSettingsPage"; +import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage"; import ProviderBaseAdvancedSettingsPage, { ClientAssertionSigningAlg, ClientAuthentication, PromptSelect, } from "../support/pages/admin-ui/manage/identity_providers/ProviderBaseAdvancedSettingsPage"; +import ProviderBaseGeneralSettingsPage from "../support/pages/admin-ui/manage/identity_providers/ProviderBaseGeneralSettingsPage"; +import ModalUtils from "../support/util/ModalUtils"; +import { keycloakBefore } from "../support/util/keycloak_hooks"; describe("OIDC identity provider test", () => { const loginPage = new LoginPage(); @@ -27,9 +28,8 @@ describe("OIDC identity provider test", () => { const deletePrompt = "Delete provider?"; const deleteSuccessMsg = "Provider successfully deleted."; - const keycloakServer = Cypress.env("KEYCLOAK_SERVER"); - const discoveryUrl = `${keycloakServer}/realms/master/.well-known/openid-configuration`; - const authorizationUrl = `${keycloakServer}/realms/master/protocol/openid-connect/auth`; + const discoveryUrl = `${SERVER_URL}/realms/master/.well-known/openid-configuration`; + const authorizationUrl = `${SERVER_URL}/realms/master/protocol/openid-connect/auth`; describe("OIDC Identity provider creation", () => { const oidcProviderName = "oidc"; diff --git a/js/apps/admin-ui/cypress/e2e/identity_providers_saml_test.spec.ts b/js/apps/admin-ui/cypress/e2e/identity_providers_saml_test.spec.ts index f8e79d6dd148..32b935f5f495 100644 --- a/js/apps/admin-ui/cypress/e2e/identity_providers_saml_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/identity_providers_saml_test.spec.ts @@ -1,12 +1,13 @@ -import Masthead from "../support/pages/admin-ui/Masthead"; -import SidebarPage from "../support/pages/admin-ui/SidebarPage"; +import { SERVER_URL } from "../support/constants"; import LoginPage from "../support/pages/LoginPage"; -import { keycloakBefore } from "../support/util/keycloak_hooks"; import ListingPage from "../support/pages/admin-ui/ListingPage"; -import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage"; -import ModalUtils from "../support/util/ModalUtils"; +import Masthead from "../support/pages/admin-ui/Masthead"; +import SidebarPage from "../support/pages/admin-ui/SidebarPage"; import AddMapperPage from "../support/pages/admin-ui/manage/identity_providers/AddMapperPage"; +import CreateProviderPage from "../support/pages/admin-ui/manage/identity_providers/CreateProviderPage"; import ProviderSAMLSettings from "../support/pages/admin-ui/manage/identity_providers/social/ProviderSAMLSettings"; +import ModalUtils from "../support/util/ModalUtils"; +import { keycloakBefore } from "../support/util/keycloak_hooks"; describe("SAML identity provider test", () => { const loginPage = new LoginPage(); @@ -28,8 +29,7 @@ describe("SAML identity provider test", () => { const classRefName = "acClassRef-1"; const declRefName = "acDeclRef-1"; - const keycloakServer = Cypress.env("KEYCLOAK_SERVER"); - const samlDiscoveryUrl = `${keycloakServer}/realms/master/protocol/saml/descriptor`; + const samlDiscoveryUrl = `${SERVER_URL}/realms/master/protocol/saml/descriptor`; const samlDisplayName = "saml"; describe("SAML identity provider creation", () => { diff --git a/js/apps/admin-ui/cypress/e2e/realm_settings_general_tab_test.spec.ts b/js/apps/admin-ui/cypress/e2e/realm_settings_general_tab_test.spec.ts index 4eff70a0e24f..dd7e2c71f50b 100644 --- a/js/apps/admin-ui/cypress/e2e/realm_settings_general_tab_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/realm_settings_general_tab_test.spec.ts @@ -1,10 +1,11 @@ import { v4 as uuid } from "uuid"; -import SidebarPage from "../support/pages/admin-ui/SidebarPage"; +import { SERVER_URL } from "../support/constants"; import LoginPage from "../support/pages/LoginPage"; -import RealmSettingsPage from "../support/pages/admin-ui/manage/realm_settings/RealmSettingsPage"; import Masthead from "../support/pages/admin-ui/Masthead"; -import { keycloakBefore } from "../support/util/keycloak_hooks"; +import SidebarPage from "../support/pages/admin-ui/SidebarPage"; +import RealmSettingsPage from "../support/pages/admin-ui/manage/realm_settings/RealmSettingsPage"; import adminClient from "../support/util/AdminClient"; +import { keycloakBefore } from "../support/util/keycloak_hooks"; const loginPage = new LoginPage(); const sidebarPage = new SidebarPage(); @@ -138,9 +139,7 @@ describe("Realm settings general tab tests", () => { .should( "have.attr", "href", - `${Cypress.env( - "KEYCLOAK_SERVER", - )}/realms/${realmName}/.well-known/openid-configuration`, + `${SERVER_URL}/realms/${realmName}/.well-known/openid-configuration`, ) .should("have.attr", "target", "_blank") .should("have.attr", "rel", "noreferrer noopener"); @@ -163,9 +162,7 @@ describe("Realm settings general tab tests", () => { .should( "have.attr", "href", - `${Cypress.env( - "KEYCLOAK_SERVER", - )}/realms/${realmName}/protocol/saml/descriptor`, + `${SERVER_URL}/realms/${realmName}/protocol/saml/descriptor`, ) .should("have.attr", "target", "_blank") .should("have.attr", "rel", "noreferrer noopener"); diff --git a/js/apps/admin-ui/cypress/support/constants.ts b/js/apps/admin-ui/cypress/support/constants.ts new file mode 100644 index 000000000000..74c2fae875ae --- /dev/null +++ b/js/apps/admin-ui/cypress/support/constants.ts @@ -0,0 +1 @@ +export const SERVER_URL = "http://localhost:8080"; diff --git a/js/apps/admin-ui/cypress/support/e2e.ts b/js/apps/admin-ui/cypress/support/e2e.ts index 3bfd6d1f3632..d076cec9fd36 100644 --- a/js/apps/admin-ui/cypress/support/e2e.ts +++ b/js/apps/admin-ui/cypress/support/e2e.ts @@ -18,8 +18,3 @@ import "./commands"; // Alternatively you can use CommonJS syntax: // require('./commands') - -// Set Keycloak server to development path if not set. -if (!Cypress.env("KEYCLOAK_SERVER")) { - Cypress.env("KEYCLOAK_SERVER", "http://localhost:8180"); -} diff --git a/js/apps/admin-ui/cypress/support/util/AdminClient.ts b/js/apps/admin-ui/cypress/support/util/AdminClient.ts index 01b995614398..5cc9cafc30b3 100644 --- a/js/apps/admin-ui/cypress/support/util/AdminClient.ts +++ b/js/apps/admin-ui/cypress/support/util/AdminClient.ts @@ -9,10 +9,11 @@ import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import { Credentials } from "@keycloak/keycloak-admin-client/lib/utils/auth"; import { merge } from "lodash-es"; +import { SERVER_URL } from "../constants"; class AdminClient { readonly #client = new KeycloakAdminClient({ - baseUrl: Cypress.env("KEYCLOAK_SERVER"), + baseUrl: SERVER_URL, realmName: "master", }); diff --git a/js/apps/admin-ui/index.html b/js/apps/admin-ui/index.html deleted file mode 100644 index 90461c502565..000000000000 --- a/js/apps/admin-ui/index.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - Keycloak Administration UI - - - -
-
-
- - - - - -
-

Loading the Admin UI

-
-
-
-
- - - - - diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/index.ftl b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/index.ftl new file mode 100644 index 000000000000..a216ae052a9e --- /dev/null +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/index.ftl @@ -0,0 +1,126 @@ + + + + + + + + + ${properties.title!'Keycloak Administration Console'} + + + <#if devServerUrl?has_content> + + + + + + <#if entryStyles?has_content> + <#list entryStyles as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + <#if entryScript?has_content> + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if entryImports?has_content> + <#list entryImports as import> + + + + + +
+
+
+ + + + + +
+

Loading the Administration Console

+
+
+
+
+ + + + diff --git a/js/apps/admin-ui/pom.xml b/js/apps/admin-ui/pom.xml index adcc43a65807..4e7a668c8938 100644 --- a/js/apps/admin-ui/pom.xml +++ b/js/apps/admin-ui/pom.xml @@ -70,92 +70,6 @@ com.github.eirslett frontend-maven-plugin - - com.google.code.maven-replacer-plugin - maven-replacer-plugin - - - process-resources - - replace - - - - - dist/index.html - target/classes/theme/keycloak.v2/admin/index.ftl - false - - - src="./ - src="${resourceUrl}/ - - - href="./ - href="${resourceUrl}/ - - - ]]> - ]]> - - - ]]> - ]]> - - - Keycloak Administration UI]]> - -${properties.title!"Keycloak Administration UI"} - -]]> - - - ]]> - - - { - "authUrl": "${authUrl}", - "authServerUrl": "${authServerUrl}", - "realm": "${loginRealm!"master"}", - "clientId": "${clientId}", - "resourceUrl": "${resourceUrl}", - "logo": "${properties.logo!""}", - "logoUrl": "${properties.logoUrl!""}", - "consoleBaseUrl": "${consoleBaseUrl}", - "masterRealm": "${masterRealm}", - "resourceVersion": "${resourceVersion}" - } - - -]]> - - - - ]]> - - - <#list properties.styles?split(' ') as style> - - - - -]]> - - - - - \ No newline at end of file diff --git a/js/apps/admin-ui/src/authentication/__tests__/FlowDiagram.test.tsx b/js/apps/admin-ui/src/authentication/__tests__/FlowDiagram.test.tsx deleted file mode 100644 index daf6f57c2ee9..000000000000 --- a/js/apps/admin-ui/src/authentication/__tests__/FlowDiagram.test.tsx +++ /dev/null @@ -1,637 +0,0 @@ -// eslint-disable-next-line no-restricted-imports, @typescript-eslint/no-unused-vars -import * as React from "react"; -import { render } from "@testing-library/react"; -import { FlowDiagram } from "../components/FlowDiagram"; -import { describe, expect, it, beforeEach } from "vitest"; -import { ExecutionList } from "../execution-model"; - -// mock react-flow -// code from https://reactflow.dev/learn/advanced-use/testing -class ResizeObserver { - callback: globalThis.ResizeObserverCallback; - - constructor(callback: globalThis.ResizeObserverCallback) { - this.callback = callback; - } - - observe(target: Element) { - this.callback([{ target } as globalThis.ResizeObserverEntry], this); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - unobserve() {} - // eslint-disable-next-line @typescript-eslint/no-empty-function - disconnect() {} -} - -class DOMMatrixReadOnly { - m22: number; - constructor(transform: string) { - const scale = transform.match(/scale\(([1-9.])\)/)?.[1]; - this.m22 = scale !== undefined ? +scale : 1; - } -} - -// Only run the shim once when requested -let init = false; - -export const mockReactFlow = () => { - if (init) return; - init = true; - - global.ResizeObserver = ResizeObserver; - - // @ts-ignore - global.DOMMatrixReadOnly = DOMMatrixReadOnly; - - Object.defineProperties(global.HTMLElement.prototype, { - offsetHeight: { - get() { - return parseFloat(this.style.height) || 1; - }, - }, - offsetWidth: { - get() { - return parseFloat(this.style.width) || 1; - }, - }, - }); - - (global.SVGElement as any).prototype.getBBox = () => ({ - x: 0, - y: 0, - width: 0, - height: 0, - }); -}; - -describe("", () => { - beforeEach(() => { - mockReactFlow(); - }); - - const reactFlowTester = (container: HTMLElement) => ({ - expectEdgeLabels: (expectedEdges: string[]) => { - const edges = Array.from( - container.getElementsByClassName("react-flow__edge"), - ); - expect( - edges.map((edge) => edge.getAttribute("aria-label")).sort(), - ).toEqual(expectedEdges.sort()); - }, - expectNodeIds: (expectedNodes: string[]) => { - const nodes = Array.from( - container.getElementsByClassName("react-flow__node"), - ); - expect(nodes.map((node) => node.getAttribute("data-id")).sort()).toEqual( - expectedNodes.sort(), - ); - }, - }); - - it("should render a flow with one required step", () => { - const executionList = new ExecutionList([ - { id: "single", displayName: "Single", level: 0 }, - ]); - - const { container } = render(); - - // const nodes = Array.from(container.getElementsByClassName("react-flow__node")); - const testHelper = reactFlowTester(container); - - const expectedEdges = [ - "Edge from start to single", - "Edge from single to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - - const expectedNodes = new Set(["start", "single", "end"]); - testHelper.expectNodeIds(Array.from(expectedNodes)); - }); - - it("should render a start connected to end with no steps", () => { - const executionList = new ExecutionList([]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - - const expectedEdges = ["Edge from start to end"]; - testHelper.expectEdgeLabels(expectedEdges); - - const expectedNodes = new Set(["start", "end"]); - testHelper.expectNodeIds(Array.from(expectedNodes)); - }); - - it("should render two branches with two alternative steps", () => { - const executionList = new ExecutionList([ - { - id: "alt1", - displayName: "Alt1", - requirement: "ALTERNATIVE", - }, - { - id: "alt2", - displayName: "Alt2", - requirement: "ALTERNATIVE", - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - - const expectedEdges = [ - "Edge from start to alt1", - "Edge from alt1 to end", - "Edge from alt1 to alt2", - "Edge from alt2 to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - - const expectedNodes = new Set(["start", "alt1", "alt2", "end"]); - testHelper.expectNodeIds(Array.from(expectedNodes)); - }); - - it("should render a flow with a subflow", () => { - const executionList = new ExecutionList([ - { - id: "requiredElement", - displayName: "Required Element", - requirement: "REQUIRED", - level: 0, - }, - { - id: "subflow", - displayName: "Subflow", - requirement: "REQUIRED", - level: 0, - }, - { - id: "subElement", - displayName: "Sub Element", - requirement: "REQUIRED", - level: 1, - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - const expectedNodes = ["start", "requiredElement", "subElement", "end"]; - testHelper.expectNodeIds(expectedNodes); - - const expectedEdges = [ - "Edge from start to requiredElement", - "Edge from requiredElement to subElement", - "Edge from subElement to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - }); - - it("should render a flow with a subflow with alternative steps", () => { - const executionList = new ExecutionList([ - { - id: "requiredElement", - displayName: "Required Element", - requirement: "REQUIRED", - level: 0, - }, - { - id: "subflow", - displayName: "Subflow", - requirement: "REQUIRED", - level: 0, - }, - { - id: "subElement1", - displayName: "Sub Element", - requirement: "ALTERNATIVE", - level: 1, - }, - { - id: "subElement2", - displayName: "Sub Element", - requirement: "ALTERNATIVE", - level: 1, - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - const expectedEdges = [ - "Edge from start to requiredElement", - "Edge from requiredElement to subElement1", - "Edge from subElement1 to end", - "Edge from subElement1 to subElement2", - "Edge from subElement2 to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - - const expectedNodes = [ - "start", - "requiredElement", - "subElement1", - "subElement2", - "end", - ]; - testHelper.expectNodeIds(expectedNodes); - }); - - it("should render a flow with a subflow with alternative steps and combine to a required step", () => { - const executionList = new ExecutionList([ - { - id: "requiredElement", - displayName: "Required Element", - requirement: "REQUIRED", - level: 0, - }, - { - id: "subflow", - displayName: "Subflow", - requirement: "REQUIRED", - level: 0, - }, - { - id: "subElement1", - displayName: "Sub Element", - requirement: "ALTERNATIVE", - level: 1, - }, - { - id: "subElement2", - displayName: "Sub Element", - requirement: "ALTERNATIVE", - level: 1, - }, - { - id: "finalStep", - displayName: "Final Step", - requirement: "REQUIRED", - level: 0, - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - const expectedEdges = [ - "Edge from start to requiredElement", - "Edge from requiredElement to subElement1", - "Edge from subElement1 to finalStep", - "Edge from subElement1 to subElement2", - "Edge from subElement2 to finalStep", - "Edge from finalStep to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - - const expectedNodes = [ - "start", - "requiredElement", - "subElement1", - "subElement2", - "finalStep", - "end", - ]; - testHelper.expectNodeIds(expectedNodes); - }); - - it("should render a flow with a conditional subflow followed by a required step", () => { - const executionList = new ExecutionList([ - { - id: "chooseUser", - displayName: "Required Element", - requirement: "REQUIRED", - level: 0, - }, - { - id: "sendReset", - displayName: "Send Reset", - requirement: "REQUIRED", - level: 0, - }, - { - id: "conditionalOTP", - displayName: "Conditional OTP", - requirement: "CONDITIONAL", - level: 0, - }, - { - id: "conditionOtpConfigured", - displayName: "Condition - User Configured", - requirement: "REQUIRED", - level: 1, - }, - { - id: "otpForm", - displayName: "OTP Form", - requirement: "REQUIRED", - level: 1, - }, - { - id: "resetPassword", - displayName: "Reset Password", - requirement: "REQUIRED", - level: 0, - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - const expectedNodes = [ - "start", - "chooseUser", - "sendReset", - "conditionOtpConfigured", - "otpForm", - "resetPassword", - "end", - ]; - testHelper.expectNodeIds(expectedNodes); - - const expectedEdges = [ - "Edge from start to chooseUser", - "Edge from chooseUser to sendReset", - "Edge from sendReset to conditionOtpConfigured", - "Edge from conditionOtpConfigured to otpForm", - "Edge from conditionOtpConfigured to resetPassword", - "Edge from otpForm to resetPassword", - "Edge from resetPassword to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - }); - - it("should render a complex flow with serial conditionals", () => { - // flow inspired by ![conditional flow PR](https://github.com/keycloak/keycloak/pull/28481) - const executionList = new ExecutionList([ - { - id: "exampleForms", - displayName: "Example Forms", - requirement: "ALTERNATIVE", - level: 0, - }, - { - id: "usernamePasswordForm", - displayName: "Username Password Form", - requirement: "REQUIRED", - level: 1, - }, - { - id: "conditionalOTP", - displayName: "Conditional OTP", - requirement: "CONDITIONAL", - level: 1, - }, - { - id: "conditionUserConfigured", - displayName: "Condition - User Configured", - requirement: "REQUIRED", - level: 2, - }, - { - id: "conditionUserAttribute", - displayName: "Condition - User Attribute", - requirement: "REQUIRED", - level: 2, - }, - { - id: "otpForm", - displayName: "OTP Form", - requirement: "REQUIRED", - level: 2, - }, - { - id: "confirmLink", - displayName: "Confirm Link", - requirement: "REQUIRED", - level: 2, - }, - { - id: "conditionalReviewProfile", - displayName: "Conditional Review Profile", - requirement: "CONDITIONAL", - level: 0, - }, - { - id: "conditionLoa", - displayName: "Condition - Loa", - requirement: "REQUIRED", - level: 1, - }, - { - id: "reviewProfile", - displayName: "Review Profile", - requirement: "REQUIRED", - level: 1, - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - - const expectedNodes = [ - "start", - "usernamePasswordForm", - "conditionUserConfigured", - "conditionUserAttribute", - "otpForm", - "confirmLink", - "conditionLoa", - "reviewProfile", - "end", - ]; - testHelper.expectNodeIds(expectedNodes); - - const expectedEdges = [ - "Edge from start to usernamePasswordForm", - "Edge from usernamePasswordForm to conditionUserConfigured", - "Edge from conditionUserConfigured to conditionUserAttribute", - "Edge from conditionUserConfigured to end", - "Edge from conditionUserAttribute to otpForm", - "Edge from conditionUserAttribute to end", - "Edge from otpForm to confirmLink", - "Edge from confirmLink to end", - "Edge from usernamePasswordForm to conditionLoa", - "Edge from conditionLoa to reviewProfile", - "Edge from conditionLoa to end", - "Edge from reviewProfile to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - }); - - it("should render the default first broker login flow", () => { - const executionList = new ExecutionList([ - { - id: "reviewProfile", - displayName: "Review Profile", - requirement: "REQUIRED", - level: 0, - }, - { - id: "createOrLink", - displayName: "User creation or linking", - requirement: "REQUIRED", - level: 0, - }, - { - id: "createUnique", - displayName: "Create User If Unique", - requirement: "ALTERNATIVE", - level: 1, - }, - { - id: "existingAccount", - displayName: "Handle Existing Account", - requirement: "ALTERNATIVE", - level: 1, - }, - { - id: "confirmLink", - displayName: "Confirm link existing account", - requirement: "REQUIRED", - level: 2, - }, - { - id: "accountVerification", - displayName: "Account verification options", - requirement: "REQUIRED", - level: 2, - }, - { - id: "emailVerify", - displayName: "Verify existing account by Email", - requirement: "ALTERNATIVE", - level: 3, - }, - { - id: "reauthVerify", - displayName: "Verify Existing Account by Re-authentication", - requirement: "ALTERNATIVE", - level: 3, - }, - { - id: "usernamePassword", - displayName: - "Username Password Form for identity provider reauthentication", - requirement: "REQUIRED", - level: 4, - }, - { - id: "conditionalOtp", - displayName: "First broker login - Conditional OTP", - requirement: "CONDITIONAL", - level: 4, - }, - { - id: "conditionUserConfigured", - displayName: "Condition - user configured", - requirement: "REQUIRED", - level: 5, - }, - { - id: "otpForm", - displayName: "OTP Form", - requirement: "REQUIRED", - level: 5, - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - - const expectedNodes = [ - "start", - "reviewProfile", - "createUnique", - "confirmLink", - "usernamePassword", - "conditionUserConfigured", - "otpForm", - "emailVerify", - "end", - ]; - testHelper.expectNodeIds(expectedNodes); - - const expectedEdges = [ - "Edge from start to reviewProfile", - "Edge from reviewProfile to createUnique", - "Edge from createUnique to confirmLink", - "Edge from createUnique to end", - "Edge from confirmLink to emailVerify", - "Edge from emailVerify to usernamePassword", - "Edge from usernamePassword to conditionUserConfigured", - "Edge from conditionUserConfigured to otpForm", - "Edge from conditionUserConfigured to end", - "Edge from otpForm to end", - "Edge from emailVerify to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - }); - - it("should hide disabled steps", () => { - const executionList = new ExecutionList([ - { - id: "disabled", - displayName: "Disabled", - requirement: "DISABLED", - }, - { - id: "required", - displayName: "Required", - requirement: "REQUIRED", - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - - const expectedNodes = ["start", "required", "end"]; - testHelper.expectNodeIds(expectedNodes); - - const expectedEdges = [ - "Edge from start to required", - "Edge from required to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - }); - - it("should hide disabled subflow", () => { - const executionList = new ExecutionList([ - { - id: "required", - displayName: "Required", - requirement: "REQUIRED", - level: 0, - }, - { - id: "subflow", - displayName: "Subflow", - requirement: "DISABLED", - level: 0, - }, - { - id: "subElement", - displayName: "Sub Element", - requirement: "REQUIRED", - level: 1, - }, - ]); - - const { container } = render(); - - const testHelper = reactFlowTester(container); - const expectedNodes = ["start", "required", "end"]; - testHelper.expectNodeIds(expectedNodes); - - const expectedEdges = [ - "Edge from start to required", - "Edge from required to end", - ]; - testHelper.expectEdgeLabels(expectedEdges); - }); -}); diff --git a/js/apps/admin-ui/vite.config.ts b/js/apps/admin-ui/vite.config.ts index cc77e1d4a1e7..0d7b511bef9e 100644 --- a/js/apps/admin-ui/vite.config.ts +++ b/js/apps/admin-ui/vite.config.ts @@ -6,14 +6,17 @@ import { checker } from "vite-plugin-checker"; export default defineConfig({ base: "", server: { - port: 8080, + origin: "http://localhost:5174", + port: 5174, }, build: { sourcemap: true, target: "esnext", modulePreload: false, cssMinify: "lightningcss", + manifest: true, rollupOptions: { + input: "src/main.tsx", external: ["react", "react/jsx-runtime", "react-dom"], }, }, diff --git a/js/apps/keycloak-server/README.md b/js/apps/keycloak-server/README.md index f8d93ef1f25e..45fbeef8f44c 100644 --- a/js/apps/keycloak-server/README.md +++ b/js/apps/keycloak-server/README.md @@ -6,22 +6,39 @@ This app allows you to run a local development version of the Keycloak server. First, ensure that all dependencies are installed locally using PNPM by running: -```bash +```sh pnpm install ``` After the dependencies are installed we can start the Keycloak server by running the following command: -```bash +```sh pnpm start ``` -This will download the [Nightly version](https://github.com/keycloak/keycloak/releases/tag/nightly) of the Keycloak server and run it locally on port `8180`. If a previously downloaded version was found in the `server/` directory then that one will be used instead. If you want to download the latest Nightly version you can remove the server directory before running the command to start the server. +If you want to run the server against a local development Vite server, you'll have to pass the `--admin-dev` or `--account-dev` flag: + +```sh +pnpm start --admin-dev +pnpm start --account-dev +``` + +The above commands will download the [Nightly version](https://github.com/keycloak/keycloak/releases/tag/nightly) of the Keycloak server and run it locally on port `8080`. If a previously downloaded version was found in the `server/` directory then that one will be used instead. If you want to download the latest Nightly version you can remove the server directory before running the command to start the server: + +```sh +pnpm delete-server +``` + +Or if you just want to clear the data so you can start fresh without downloading the server again: + +```sh +pnpm delete-data +``` If you want to run with a local Quarkus distribution of Keycloak for development purposes, you can do so by running this command instead: -```bash -pnpm start -- --local +```sh +pnpm start --local ``` **All other arguments will be passed through to the underlying Keycloak server.** diff --git a/js/apps/keycloak-server/package.json b/js/apps/keycloak-server/package.json index 7e55fd26bc40..ed44fd5ecced 100644 --- a/js/apps/keycloak-server/package.json +++ b/js/apps/keycloak-server/package.json @@ -2,20 +2,11 @@ "name": "keycloak-server", "type": "module", "scripts": { - "start": "wireit", + "start": "node ./scripts/start-server.js", "delete-data": "rm -r ./server/data", "delete-server": "rm -r ./server" }, - "wireit": { - "start": { - "command": "node ./scripts/start-server.js", - "dependencies": [ - "../../libs/keycloak-admin-client:build" - ] - } - }, "dependencies": { - "@keycloak/keycloak-admin-client": "workspace:*", "@octokit/rest": "^20.1.1", "@types/gunzip-maybe": "^1.4.2", "@types/tar-fs": "^2.0.4", diff --git a/js/apps/keycloak-server/scripts/security-admin-console-v2.json b/js/apps/keycloak-server/scripts/security-admin-console-v2.json deleted file mode 100644 index 0a7347683755..000000000000 --- a/js/apps/keycloak-server/scripts/security-admin-console-v2.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "clientId": "security-admin-console-v2", - "rootUrl": "http://localhost:8080/", - "adminUrl": "http://localhost:8080/", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "redirectUris": [ - "http://localhost:8080/*" - ], - "webOrigins": [ - "http://localhost:8080" - ], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": true, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "security.admin.console": "true" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-origins", - "role_list", - "roles", - "profile", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ], - "access": { - "view": true, - "configure": true, - "manage": true - } -} diff --git a/js/apps/keycloak-server/scripts/start-server.js b/js/apps/keycloak-server/scripts/start-server.js index 513358ce487b..6e2014082ffb 100755 --- a/js/apps/keycloak-server/scripts/start-server.js +++ b/js/apps/keycloak-server/scripts/start-server.js @@ -1,10 +1,8 @@ #!/usr/bin/env node -import KcAdminClient from "@keycloak/keycloak-admin-client"; import { Octokit } from "@octokit/rest"; import gunzip from "gunzip-maybe"; import { spawn } from "node:child_process"; import fs from "node:fs"; -import { readFile } from "node:fs/promises"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; @@ -18,13 +16,17 @@ const LOCAL_DIST_NAME = "keycloak-999.0.0-SNAPSHOT.tar.gz"; const SCRIPT_EXTENSION = process.platform === "win32" ? ".bat" : ".sh"; const ADMIN_USERNAME = "admin"; const ADMIN_PASSWORD = "admin"; -const AUTH_DELAY = 10000; -const AUTH_RETRY_LIMIT = 3; const options = { local: { type: "boolean", }, + "account-dev": { + type: "boolean", + }, + "admin-dev": { + type: "boolean", + }, }; await startServer(); @@ -34,30 +36,37 @@ async function startServer() { await downloadServer(scriptArgs.local); + const env = { + KEYCLOAK_ADMIN: ADMIN_USERNAME, + KEYCLOAK_ADMIN_PASSWORD: ADMIN_PASSWORD, + ...process.env, + }; + + if (scriptArgs["account-dev"]) { + env.KC_ACCOUNT_VITE_URL = "http://localhost:5173"; + } + + if (scriptArgs["admin-dev"]) { + env.KC_ADMIN_VITE_URL = "http://localhost:5174"; + } + console.info("Starting server…"); + const child = spawn( path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`), [ "start-dev", - "--http-port=8180", `--features="login2,account3,admin-fine-grained-authz,transient-users,oid4vc-vci"`, ...keycloakArgs, ], { shell: true, - env: { - KEYCLOAK_ADMIN: ADMIN_USERNAME, - KEYCLOAK_ADMIN_PASSWORD: ADMIN_PASSWORD, - ...process.env, - }, + env, }, ); child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr); - - await wait(AUTH_DELAY); - await importClient(); } function handleArgs(args) { @@ -102,35 +111,6 @@ async function downloadServer(local) { await extractTarball(assetStream, SERVER_DIR, { strip: 1 }); } -async function importClient() { - const adminClient = new KcAdminClient({ - baseUrl: "http://127.0.0.1:8180", - realmName: "master", - }); - - await authenticateAdminClient(adminClient); - - console.info("Checking if client already exists…"); - - const adminConsoleClient = await adminClient.clients.find({ - clientId: "security-admin-console-v2", - }); - - if (adminConsoleClient.length > 0) { - console.info("Client already exists, skipping import."); - return; - } - - console.info("Importing client…"); - - const configPath = path.join(DIR_NAME, "security-admin-console-v2.json"); - const config = JSON.parse(await readFile(configPath, "utf-8")); - - await adminClient.clients.create(config); - - console.info("Client imported successfully."); -} - async function getNightlyAsset() { const api = new Octokit(); const release = await api.repos.getReleaseByTag({ @@ -157,36 +137,3 @@ async function getAssetAsStream(asset) { function extractTarball(stream, path, options) { return pipeline(stream, gunzip(), extract(path, options)); } - -async function authenticateAdminClient( - adminClient, - numRetries = AUTH_RETRY_LIMIT, -) { - console.log("Authenticating admin client…"); - - try { - await adminClient.auth({ - username: ADMIN_USERNAME, - password: ADMIN_PASSWORD, - grantType: "password", - clientId: "admin-cli", - }); - } catch (error) { - if (numRetries === 0) { - throw error; - } - - console.info( - `Authentication failed, retrying in ${AUTH_DELAY / 1000} seconds.`, - ); - - await wait(AUTH_DELAY); - await authenticateAdminClient(adminClient, numRetries - 1); - } - - console.log("Admin client authenticated successfully."); -} - -async function wait(delay) { - return new Promise((resolve) => setTimeout(() => resolve(), delay)); -} diff --git a/js/libs/ui-shared/src/context/environment.ts b/js/libs/ui-shared/src/context/environment.ts index 62e8fed7bc21..528e37d377c1 100644 --- a/js/libs/ui-shared/src/context/environment.ts +++ b/js/libs/ui-shared/src/context/environment.ts @@ -20,25 +20,21 @@ export type BaseEnvironment = { }; /** - * Extracts the environment variables that are passed if the application is running as a Keycloak theme and combines them with the provided defaults. - * These variables are injected by Keycloak into the `index.ftl` as a script tag, the contents of which can be parsed as JSON. + * Extracts the environment variables from the document, these variables are injected by Keycloak as a script tag, the contents of which can be parsed as JSON. * - * @argument defaults - The default values to fall to if a value is not present in the environment. + * @argument defaults - The default values to fall to if a value is not found in the environment. */ export function getInjectedEnvironment(defaults: T): T { const element = document.getElementById("environment"); - let env = {} as T; + const contents = element?.textContent; + + if (typeof contents !== "string") { + throw new Error("Environment variables not found in the document."); + } - // Attempt to parse the contents as JSON and return its value. try { - // If the element cannot be found, return an empty record. - if (element?.textContent) { - env = JSON.parse(element.textContent); - } + return { ...defaults, ...JSON.parse(contents) }; } catch (error) { - console.error("Unable to parse environment variables."); + throw new Error("Unable to parse environment variables as JSON."); } - - // Return the merged environment variables with the defaults. - return { ...defaults, ...env }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6df0628ffa6..83d701a6215b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,9 +298,6 @@ importers: js/apps/keycloak-server: dependencies: - '@keycloak/keycloak-admin-client': - specifier: workspace:* - version: link:../../libs/keycloak-admin-client '@octokit/rest': specifier: ^20.1.1 version: 20.1.1 diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index cb39a811ac80..e3e110e0182e 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -541,10 +541,10 @@ void persistBuildTimeProperties(BuildProducer resour Configuration.markAsOptimized(properties); } - String profile = Environment.getProfile(); + String profile = org.keycloak.common.util.Environment.getProfile(); if (profile != null) { - properties.put(Environment.PROFILE, profile); + properties.put(org.keycloak.common.util.Environment.PROFILE, profile); properties.put(LaunchMode.current().getProfileKey(), profile); } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Environment.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Environment.java index 0ad19dadf61a..b8a20581ab29 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Environment.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Environment.java @@ -43,11 +43,8 @@ public final class Environment { public static final String IMPORT_EXPORT_MODE = "import_export"; - public static final String PROFILE ="kc.profile"; - public static final String ENV_PROFILE ="KC_PROFILE"; public static final String DATA_PATH = File.separator + "data"; public static final String DEFAULT_THEMES_PATH = File.separator + "themes"; - public static final String DEV_PROFILE_VALUE = "dev"; public static final String PROD_PROFILE_VALUE = "prod"; public static final String LAUNCH_MODE = "kc.launch.mode"; @@ -103,18 +100,8 @@ public static String getCommand() { return "kc.sh"; } - public static String getProfile() { - String profile = System.getProperty(PROFILE); - - if (profile == null) { - profile = System.getenv(ENV_PROFILE); - } - - return profile; - } - public static void setProfile(String profile) { - System.setProperty(PROFILE, profile); + System.setProperty(org.keycloak.common.util.Environment.PROFILE, profile); System.setProperty(LaunchMode.current().getProfileKey(), profile); System.setProperty(SmallRyeConfig.SMALLRYE_CONFIG_PROFILE, profile); if (isTestLaunchMode()) { @@ -123,15 +110,15 @@ public static void setProfile(String profile) { } public static String getCurrentOrPersistedProfile() { - String profile = getProfile(); + String profile = org.keycloak.common.util.Environment.getProfile(); if(profile == null) { - profile = PersistedConfigSource.getInstance().getValue(PROFILE); + profile = PersistedConfigSource.getInstance().getValue(org.keycloak.common.util.Environment.PROFILE); } return profile; } public static String getProfileOrDefault(String defaultProfile) { - String profile = getProfile(); + String profile = org.keycloak.common.util.Environment.getProfile(); if (profile == null) { profile = defaultProfile; @@ -141,19 +128,19 @@ public static String getProfileOrDefault(String defaultProfile) { } public static boolean isDevMode() { - if (DEV_PROFILE_VALUE.equalsIgnoreCase(getProfile())) { + if (org.keycloak.common.util.Environment.isDevMode()) { return true; } - return DEV_PROFILE_VALUE.equals(getBuildTimeProperty(PROFILE).orElse(null)); + return org.keycloak.common.util.Environment.DEV_PROFILE_VALUE.equals(getBuildTimeProperty(org.keycloak.common.util.Environment.PROFILE).orElse(null)); } public static boolean isDevProfile(){ - return Optional.ofNullable(getProfile()).orElse("").equalsIgnoreCase(DEV_PROFILE_VALUE); + return Optional.ofNullable(org.keycloak.common.util.Environment.getProfile()).orElse("").equalsIgnoreCase(org.keycloak.common.util.Environment.DEV_PROFILE_VALUE); } public static boolean isImportExportMode() { - return IMPORT_EXPORT_MODE.equalsIgnoreCase(getProfile()); + return IMPORT_EXPORT_MODE.equalsIgnoreCase(org.keycloak.common.util.Environment.getProfile()); } public static boolean isWindows() { @@ -161,7 +148,7 @@ public static boolean isWindows() { } public static void forceDevProfile() { - setProfile(DEV_PROFILE_VALUE); + setProfile(org.keycloak.common.util.Environment.DEV_PROFILE_VALUE); } public static Map getProviderFiles() { diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Messages.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Messages.java index 8364ebefaea4..8a4e5614c389 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Messages.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/Messages.java @@ -32,7 +32,7 @@ private Messages() { public static String httpsConfigurationNotSet() { StringBuilder builder = new StringBuilder("Key material not provided to setup HTTPS. Please configure your keys/certificates"); - if (!Environment.DEV_PROFILE_VALUE.equals(Environment.getProfile())) { + if (!org.keycloak.common.util.Environment.DEV_PROFILE_VALUE.equals(org.keycloak.common.util.Environment.getProfile())) { builder.append(" or start the server in development mode"); } builder.append("."); @@ -44,7 +44,7 @@ public static void cliExecutionError(CommandLine cmd, String message, Throwable } public static String devProfileNotAllowedError(String cmd) { - return String.format("You can not '%s' the server in %s mode. Please re-build the server first, using 'kc.sh build' for the default production mode.%n", cmd, Environment.getKeycloakModeFromProfile(Environment.DEV_PROFILE_VALUE)); + return String.format("You can not '%s' the server in %s mode. Please re-build the server first, using 'kc.sh build' for the default production mode.%n", cmd, Environment.getKeycloakModeFromProfile(org.keycloak.common.util.Environment.DEV_PROFILE_VALUE)); } public static String invalidLogLevel(String logLevel) { diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java index 4e639cf9d4e9..879817314e93 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java @@ -168,7 +168,7 @@ private static int runReAugmentationIfNeeded(List cliArgs, CommandLine c } if (currentCommandName.equals(StartDev.NAME)) { - String profile = Environment.getProfile(); + String profile = org.keycloak.common.util.Environment.getProfile(); if (profile == null) { // force the server image to be set with the dev profile @@ -461,7 +461,7 @@ private static void outputDisabledProperties(Set properties, boolean bui } private static boolean hasConfigChanges(CommandLine cmdCommand) { - Optional currentProfile = ofNullable(Environment.getProfile()); + Optional currentProfile = ofNullable(org.keycloak.common.util.Environment.getProfile()); Optional persistedProfile = getBuildTimeProperty("kc.profile"); if (!persistedProfile.orElse("").equals(currentProfile.orElse(""))) { diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java index 691249feb3e8..7effd9b10a03 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java @@ -65,7 +65,7 @@ private void devProfileNotAllowedError() { } public static boolean isDevProfileNotAllowed() { - Optional currentProfile = Optional.ofNullable(Environment.getProfile()); + Optional currentProfile = Optional.ofNullable(org.keycloak.common.util.Environment.getProfile()); Optional persistedProfile = getRawPersistedProperty("kc.profile"); setProfile(currentProfile.orElse(persistedProfile.orElse("prod"))); diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/Configuration.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/Configuration.java index b1b2ae28cfdc..3d95a101a543 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/Configuration.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/Configuration.java @@ -88,10 +88,10 @@ public static Optional getBuildTimeProperty(String name) { } if (value.isEmpty()) { - String profile = Environment.getProfile(); + String profile = org.keycloak.common.util.Environment.getProfile(); if (profile == null) { - profile = getConfig().getRawValue(Environment.PROFILE); + profile = getConfig().getRawValue(org.keycloak.common.util.Environment.PROFILE); } value = getRawPersistedProperty("%" + profile + "." + name); diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/KeycloakConfigSourceProvider.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/KeycloakConfigSourceProvider.java index ba702dad3ef2..516f86d36ace 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/KeycloakConfigSourceProvider.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/KeycloakConfigSourceProvider.java @@ -42,7 +42,7 @@ public class KeycloakConfigSourceProvider implements ConfigSourceProvider, Confi } private static void initializeSources() { - String profile = Environment.getProfile(); + String profile = org.keycloak.common.util.Environment.getProfile(); if (profile != null) { System.setProperty("quarkus.profile", profile); diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java index 38401210ce24..c31904ab1d24 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java @@ -83,7 +83,7 @@ public static boolean isBuildTimeProperty(String name) { && !ConfigArgsConfigSource.CLI_ARGS.equals(name) && !"kc.home.dir".equals(name) && !"kc.config.file".equals(name) - && !Environment.PROFILE.equals(name) + && !org.keycloak.common.util.Environment.PROFILE.equals(name) && !"kc.show.config".equals(name) && !"kc.show.config.runtime".equals(name) && !"kc.config-file".equals(name); diff --git a/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/test/ConfigurationTest.java b/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/test/ConfigurationTest.java index 6f16837907df..9107e99b090f 100644 --- a/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/test/ConfigurationTest.java +++ b/quarkus/runtime/src/test/java/org/keycloak/quarkus/runtime/configuration/test/ConfigurationTest.java @@ -185,7 +185,7 @@ public void testDefaultValue() { @Test public void testKeycloakProfilePropertySubstitution() { - System.setProperty(Environment.PROFILE, "user-profile"); + System.setProperty(org.keycloak.common.util.Environment.PROFILE, "user-profile"); assertEquals("http://filepropprofile.unittest", initConfig("hostname", "default").get("frontendUrl")); } @@ -430,11 +430,11 @@ public void testClusterConfig() { Assert.assertEquals("cache-ispn.xml", initConfig("connectionsInfinispan", "quarkus").get("configFile")); // If explicitly set, then it is always used regardless of the profile - System.clearProperty(Environment.PROFILE); + System.clearProperty(org.keycloak.common.util.Environment.PROFILE); ConfigArgsConfigSource.setCliArgs("--cache=cluster-foo.xml"); Assert.assertEquals("cluster-foo.xml", initConfig("connectionsInfinispan", "quarkus").get("configFile")); - System.setProperty(Environment.PROFILE, "dev"); + System.setProperty(org.keycloak.common.util.Environment.PROFILE, "dev"); Assert.assertEquals("cluster-foo.xml", initConfig("connectionsInfinispan", "quarkus").get("configFile")); ConfigArgsConfigSource.setCliArgs("--cache-stack=foo"); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index 16e45713d7a7..f7ffc3af697c 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -1,29 +1,14 @@ package org.keycloak.services.resources.account; -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; -import java.util.Scanner; -import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriInfo; -import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; import org.keycloak.authentication.requiredactions.DeleteAccount; import org.keycloak.common.Profile; import org.keycloak.common.Version; -import org.keycloak.events.EventStoreProvider; +import org.keycloak.common.util.Environment; import org.keycloak.models.AccountRoles; import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; @@ -40,6 +25,7 @@ import org.keycloak.services.resource.AccountResourceProvider; import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.util.ResolveRelative; +import org.keycloak.services.util.ViteManifest; import org.keycloak.services.validation.Validation; import org.keycloak.theme.FreeMarkerException; import org.keycloak.theme.Theme; @@ -49,6 +35,19 @@ import org.keycloak.util.JsonSerialization; import org.keycloak.utils.MediaType; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Scanner; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + /** * Created by st on 29/03/17. */ @@ -161,6 +160,26 @@ public Response getMainPage() throws IOException, FreeMarkerException { RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name()); map.put("updateEmailActionEnabled", updateEmailActionProvider != null && updateEmailActionProvider.isEnabled()); + final var devServerUrl = Environment.isDevMode() ? System.getenv(ViteManifest.ACCOUNT_VITE_URL) : null; + + if (devServerUrl != null) { + map.put("devServerUrl", devServerUrl); + } + + final var manifestFile = theme.getResourceAsStream(ViteManifest.MANIFEST_FILE_PATH); + + if (devServerUrl == null && manifestFile != null) { + final var manifest = ViteManifest.parseFromInputStream(manifestFile); + final var entryChunk = manifest.getEntryChunk(); + final var entryStyles = entryChunk.css().orElse(new String[] {}); + final var entryScript = entryChunk.file(); + final var entryImports = entryChunk.imports().orElse(new String[] {}); + + map.put("entryStyles", entryStyles); + map.put("entryScript", entryScript); + map.put("entryImports", entryImports); + } + FreeMarkerProvider freeMarkerUtil = session.getProvider(FreeMarkerProvider.class); String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme); Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java b/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java index 0a44e244d82b..3c010360f8db 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AdminConsole.java @@ -18,12 +18,21 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotAuthorizedException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; import org.keycloak.Config; import org.keycloak.common.ClientConnection; import org.keycloak.common.Profile; import org.keycloak.common.Version; +import org.keycloak.common.util.Environment; import org.keycloak.common.util.UriUtils; import org.keycloak.headers.SecurityHeadersProvider; import org.keycloak.http.HttpRequest; @@ -42,20 +51,13 @@ import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.ClientManager; import org.keycloak.services.managers.RealmManager; +import org.keycloak.services.util.ViteManifest; import org.keycloak.theme.FreeMarkerException; import org.keycloak.theme.Theme; import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.urls.UrlType; import org.keycloak.utils.MediaType; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotAuthorizedException; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.OPTIONS; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.Response; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -358,6 +360,26 @@ public Response getMainPage() throws IOException, FreeMarkerException { map.put("clientId", Constants.ADMIN_CONSOLE_CLIENT_ID); map.put("properties", theme.getProperties()); + final var devServerUrl = Environment.isDevMode() ? System.getenv(ViteManifest.ADMIN_VITE_URL) : null; + + if (devServerUrl != null) { + map.put("devServerUrl", devServerUrl); + } + + final var manifestFile = theme.getResourceAsStream(".vite/manifest.json"); + + if (devServerUrl == null && manifestFile != null) { + final var manifest = ViteManifest.parseFromInputStream(manifestFile); + final var entryChunk = manifest.getEntryChunk(); + final var entryStyles = entryChunk.css().orElse(new String[] {}); + final var entryScript = entryChunk.file(); + final var entryImports = entryChunk.imports().orElse(new String[] {}); + + map.put("entryStyles", entryStyles); + map.put("entryScript", entryScript); + map.put("entryImports", entryImports); + } + FreeMarkerProvider freeMarkerUtil = session.getProvider(FreeMarkerProvider.class); String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme); Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result); diff --git a/services/src/main/java/org/keycloak/services/util/Chunk.java b/services/src/main/java/org/keycloak/services/util/Chunk.java new file mode 100644 index 000000000000..58a1d5e782a4 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/util/Chunk.java @@ -0,0 +1,36 @@ +package org.keycloak.services.util; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Optional; + +/** + * Represents a chunk from the Vite build manifest (see {@link ViteManifest}). + */ +public record Chunk ( + @JsonProperty(required = true) + String file, + + @JsonProperty + Optional src, + + @JsonProperty + Optional name, + + @JsonProperty + Optional isEntry, + + @JsonProperty + Optional isDynamicEntry, + + @JsonProperty + Optional imports, + + @JsonProperty + Optional dynamicImports, + + @JsonProperty + Optional assets, + + @JsonProperty Optional css +){} diff --git a/services/src/main/java/org/keycloak/services/util/ViteManifest.java b/services/src/main/java/org/keycloak/services/util/ViteManifest.java new file mode 100644 index 000000000000..74396c935a32 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/util/ViteManifest.java @@ -0,0 +1,42 @@ +package org.keycloak.services.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Optional; + +/** + * This class is used to parse the Vite manifest file which is generated by the build, this file contains + * a mapping of non-hashed asset filenames to their hashed versions, which can then be used to render the + * correct asset links for scripts, styles, etc. + * + * @see Vite documentation — Backend Integration + */ +public class ViteManifest { + public static final String MANIFEST_FILE_PATH = ".vite/manifest.json"; + public static final String ACCOUNT_VITE_URL = "KC_ACCOUNT_VITE_URL"; + public static final String ADMIN_VITE_URL = "KC_ADMIN_VITE_URL"; + + private final HashMap manifest; + + private ViteManifest(HashMap value) { + this.manifest = value; + } + + public static ViteManifest parseFromInputStream(InputStream input) throws IOException { + final var typeRef = new TypeReference>() {}; + final var value = JsonSerialization.readValue(input, typeRef); + + return new ViteManifest(value); + } + + public Chunk getEntryChunk() { + return manifest.values().stream() + .filter(chunk -> chunk.isEntry().orElse(false)) + .findFirst() + .orElseThrow(); + } +}