diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index 6208d7ccdae..67b95b10099 100644 --- a/.github/workflows/js-ci.yml +++ b/.github/workflows/js-ci.yml @@ -230,7 +230,7 @@ jobs: - name: Start Keycloak server run: | tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz - keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users,spiffe,oid4vc-vci &> ~/server.log & + keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users,spiffe,oid4vc-vci,kubernetes-service-accounts &> ~/server.log & env: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 55724fd8163..1fcbfa88974 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -936,8 +936,11 @@ custom=Custom Attribute... keyTab=Key tab addSamlProvider=Add SAML provider addSpiffeProvider=Add SPIFFE provider +addKubernetesProvider=Add Kubernetes provider spiffeTrustDomain=SPIFFE Trust Domain spiffeBundleEndpoint=SPIFFE Bundle Endpoint +kubernetesJWKSURL=Kubernetes JWKS URL +kubernetesJWKSURLHelp=Use Kubernetes JWKS URL when accessing an external Kubernetes cluster. The JWKS endpoint must not require authentication permission=Permission saveEventListeners=Save Event Listeners capabilityConfig=Capability config diff --git a/js/apps/admin-ui/src/identity-providers/add/AddKubernetesConnect.tsx b/js/apps/admin-ui/src/identity-providers/add/AddKubernetesConnect.tsx new file mode 100644 index 00000000000..dbf7c1399c1 --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/add/AddKubernetesConnect.tsx @@ -0,0 +1,95 @@ +import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import { + ActionGroup, + AlertVariant, + Button, + PageSection, +} from "@patternfly/react-core"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { useAdminClient } from "../../admin-client"; +import { useAlerts } from "@keycloak/keycloak-ui-shared"; +import { FormAccess } from "../../components/form/FormAccess"; +import { ViewHeader } from "../../components/view-header/ViewHeader"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { toIdentityProvider } from "../routes/IdentityProvider"; +import { toIdentityProviders } from "../routes/IdentityProviders"; +import { KubernetesSettings } from "./KubernetesSettings"; + +type DiscoveryIdentityProvider = IdentityProviderRepresentation & { + discoveryEndpoint?: string; +}; + +export default function AddKubernetesConnect() { + const { adminClient } = useAdminClient(); + + const { t } = useTranslation(); + const navigate = useNavigate(); + const id = "kubernetes"; + + const form = useForm({ + defaultValues: { alias: id, config: { allowCreate: "true" } }, + mode: "onChange", + }); + const { handleSubmit } = form; + + const { addAlert, addError } = useAlerts(); + const { realm } = useRealm(); + + const onSubmit = async (provider: DiscoveryIdentityProvider) => { + delete provider.discoveryEndpoint; + try { + await adminClient.identityProviders.create({ + ...provider, + providerId: id, + }); + addAlert(t("createIdentityProviderSuccess"), AlertVariant.success); + navigate( + toIdentityProvider({ + realm, + providerId: id, + alias: provider.alias!, + tab: "settings", + }), + ); + } catch (error: any) { + addError("createIdentityProviderError", error); + } + }; + + return ( + <> + + + + + + + + + + + + + + ); +} diff --git a/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx index e5f43071297..ad4bd65930e 100644 --- a/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/DetailSettings.tsx @@ -69,6 +69,7 @@ import { SamlGeneralSettings } from "./SamlGeneralSettings"; import { SpiffeSettings } from "./SpiffeSettings"; import { AdminEvents } from "../../events/AdminEvents"; import { UserProfileClaimsSettings } from "./OAuth2UserProfileClaimsSettings"; +import { KubernetesSettings } from "./KubernetesSettings"; type HeaderProps = { onChange: (value: boolean) => void; @@ -414,6 +415,7 @@ export default function DetailSettings() { const isSAML = provider.providerId!.includes("saml"); const isOAuth2 = provider.providerId!.includes("oauth2"); const isSPIFFE = provider.providerId!.includes("spiffe"); + const isKubernetes = provider.providerId!.includes("kubernetes"); const isSocial = !isOIDC && !isSAML && !isOAuth2; const loader = async () => { @@ -444,7 +446,7 @@ export default function DetailSettings() { const sections = [ { title: t("generalSettings"), - isHidden: isSPIFFE, + isHidden: isSPIFFE || isKubernetes, panel: ( ), }, + { + title: t("generalSettings"), + isHidden: !isKubernetes, + panel: ( +
+ + + + ), + }, { title: t("samlSettings"), isHidden: !isSAML, @@ -523,7 +539,7 @@ export default function DetailSettings() { }, { title: t("advancedSettings"), - isHidden: isSPIFFE, + isHidden: isSPIFFE || isKubernetes, panel: ( {t("mappers")}} {...mappersTab} diff --git a/js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx new file mode 100644 index 00000000000..824abaa427e --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx @@ -0,0 +1,25 @@ +import { TextControl } from "@keycloak/keycloak-ui-shared"; +import { useTranslation } from "react-i18next"; + +export const KubernetesSettings = () => { + const { t } = useTranslation(); + + return ( + <> + + + + + ); +}; diff --git a/js/apps/admin-ui/src/identity-providers/routes.ts b/js/apps/admin-ui/src/identity-providers/routes.ts index 6179afc39ab..0e09963e0b0 100644 --- a/js/apps/admin-ui/src/identity-providers/routes.ts +++ b/js/apps/admin-ui/src/identity-providers/routes.ts @@ -4,6 +4,7 @@ import { IdentityProviderKeycloakOidcRoute } from "./routes/IdentityProviderKeyc import { IdentityProviderOidcRoute } from "./routes/IdentityProviderOidc"; import { IdentityProviderSamlRoute } from "./routes/IdentityProviderSaml"; import { IdentityProviderSpiffeRoute } from "./routes/IdentityProviderSpiffe"; +import { IdentityProviderKubernetesRoute } from "./routes/IdentityProviderKubernetes"; import { IdentityProvidersRoute } from "./routes/IdentityProviders"; import { IdentityProviderAddMapperRoute } from "./routes/AddMapper"; import { IdentityProviderEditMapperRoute } from "./routes/EditMapper"; @@ -17,6 +18,7 @@ const routes: AppRouteObject[] = [ IdentityProviderOidcRoute, IdentityProviderSamlRoute, IdentityProviderSpiffeRoute, + IdentityProviderKubernetesRoute, IdentityProviderKeycloakOidcRoute, IdentityProviderCreateRoute, IdentityProviderRoute, diff --git a/js/apps/admin-ui/src/identity-providers/routes/IdentityProviderKubernetes.tsx b/js/apps/admin-ui/src/identity-providers/routes/IdentityProviderKubernetes.tsx new file mode 100644 index 00000000000..e8a9ac6ec34 --- /dev/null +++ b/js/apps/admin-ui/src/identity-providers/routes/IdentityProviderKubernetes.tsx @@ -0,0 +1,23 @@ +import { lazy } from "react"; +import type { Path } from "react-router-dom"; +import { generateEncodedPath } from "../../utils/generateEncodedPath"; +import type { AppRouteObject } from "../../routes"; + +export type IdentityProviderKubernetesParams = { realm: string }; + +const AddKubernetesConnect = lazy(() => import("../add/AddKubernetesConnect")); + +export const IdentityProviderKubernetesRoute: AppRouteObject = { + path: "/:realm/identity-providers/kubernetes/add", + element: , + breadcrumb: (t) => t("addKubernetesProvider"), + handle: { + access: "manage-identity-providers", + }, +}; + +export const toIdentityProviderKubernetes = ( + params: IdentityProviderKubernetesParams, +): Partial => ({ + pathname: generateEncodedPath(IdentityProviderKubernetesRoute.path, params), +}); diff --git a/js/apps/admin-ui/test/identity-providers/kubernetes.spec.ts b/js/apps/admin-ui/test/identity-providers/kubernetes.spec.ts new file mode 100644 index 00000000000..424fa9c3eed --- /dev/null +++ b/js/apps/admin-ui/test/identity-providers/kubernetes.spec.ts @@ -0,0 +1,40 @@ +import { test } from "@playwright/test"; +import adminClient from "../utils/AdminClient.ts"; +import { login } from "../utils/login.ts"; +import { assertNotificationMessage } from "../utils/masthead.ts"; +import { goToIdentityProviders } from "../utils/sidebar.ts"; +import { clickTableRowItem } from "../utils/table.ts"; +import { clickSaveButton, createKubernetesProvider } from "./main.ts"; + +test.beforeEach(async ({ page }) => { + await login(page); + await goToIdentityProviders(page); +}); + +test.afterAll(() => adminClient.deleteIdentityProvider("kubernetes")); + +test.describe.serial("Kubernetes identity provider test", () => { + test("should create a Kubernetes provider", async ({ page }) => { + await createKubernetesProvider( + page, + "kubernetes", + "https://kubernetes.myorg.com/openid/v1/jwks", + ); + + await assertNotificationMessage( + page, + "Identity provider successfully created", + ); + + await goToIdentityProviders(page); + await clickTableRowItem(page, "kubernetes"); + + await page + .getByTestId("config.jwksUrl") + .fill("https://kubernetes.myorg2.com/openid/v1/jwks"); + + await clickSaveButton(page); + + await assertNotificationMessage(page, "Provider successfully updated"); + }); +}); diff --git a/js/apps/admin-ui/test/identity-providers/main.ts b/js/apps/admin-ui/test/identity-providers/main.ts index 1c61295b10b..718f2dbfca6 100644 --- a/js/apps/admin-ui/test/identity-providers/main.ts +++ b/js/apps/admin-ui/test/identity-providers/main.ts @@ -55,6 +55,16 @@ export async function createSPIFFEProvider( await clickAddButton(page); } +export async function createKubernetesProvider( + page: Page, + providerName: string, + jwksUrl: string, +) { + await clickProviderCard(page, providerName); + await page.getByTestId("config.jwksUrl").fill(jwksUrl); + await clickAddButton(page); +} + export async function assertAuthorizationUrl(page: Page) { await expect(page.getByTestId("config.authorizationUrl")).toHaveValue( authorizationUrl, diff --git a/js/apps/keycloak-server/scripts/start-server.js b/js/apps/keycloak-server/scripts/start-server.js index 85ef7cdde29..41331d7cb69 100755 --- a/js/apps/keycloak-server/scripts/start-server.js +++ b/js/apps/keycloak-server/scripts/start-server.js @@ -60,7 +60,7 @@ async function startServer() { path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`), [ "start-dev", - `--features="login:v2,account:v3,admin-fine-grained-authz:v2,transient-users,oid4vc-vci,organization,declarative-ui,quick-theme,spiffe"`, + `--features="login:v2,account:v3,admin-fine-grained-authz:v2,transient-users,oid4vc-vci,organization,declarative-ui,quick-theme,spiffe,kubernetes-service-accounts"`, ...keycloakArgs, ], { diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java index b925990c3b0..b1e1a105844 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProvider.java @@ -3,7 +3,6 @@ package org.keycloak.broker.kubernetes; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; import org.keycloak.broker.oidc.OIDCIdentityProvider; -import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.broker.provider.AuthenticationRequest; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityProviderDataMarshaller; @@ -24,7 +23,7 @@ public class KubernetesIdentityProvider extends OIDCIdentityProvider { private final String globalJwksUrl; - public KubernetesIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config, String globalJwksUrl) { + public KubernetesIdentityProvider(KeycloakSession session, KubernetesIdentityProviderConfig config, String globalJwksUrl) { super(session, config); this.globalJwksUrl = globalJwksUrl; } diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java new file mode 100644 index 00000000000..fddd5c6ad1a --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderConfig.java @@ -0,0 +1,24 @@ +package org.keycloak.broker.kubernetes; + +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IdentityProviderShowInAccountConsole; + +public class KubernetesIdentityProviderConfig extends OIDCIdentityProviderConfig { + + public KubernetesIdentityProviderConfig() { + this(null); + } + + public KubernetesIdentityProviderConfig(IdentityProviderModel model) { + super(model); + setHideOnLogin(true); + getConfig().put(IdentityProviderModel.SHOW_IN_ACCOUNT_CONSOLE, IdentityProviderShowInAccountConsole.NEVER.name()); + } + + @Override + public boolean isHideOnLogin() { + return true; + } + +} diff --git a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java index f5f33af46aa..95360a071aa 100644 --- a/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java +++ b/services/src/main/java/org/keycloak/broker/kubernetes/KubernetesIdentityProviderFactory.java @@ -5,6 +5,7 @@ import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.broker.provider.AbstractIdentityProviderFactory; import org.keycloak.common.Profile; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.IdentityProviderShowInAccountConsole; import org.keycloak.models.KeycloakSession; import org.keycloak.provider.EnvironmentDependentProviderFactory; @@ -26,7 +27,7 @@ public class KubernetesIdentityProviderFactory extends AbstractIdentityProviderF @Override public KubernetesIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { - return new KubernetesIdentityProvider(session, new OIDCIdentityProviderConfig(model), globalJwksUrl); + return new KubernetesIdentityProvider(session, new KubernetesIdentityProviderConfig(model), globalJwksUrl); } @Override @@ -45,7 +46,7 @@ public class KubernetesIdentityProviderFactory extends AbstractIdentityProviderF @Override public IdentityProviderModel createConfig() { - return new OIDCIdentityProviderConfig(); + return new KubernetesIdentityProviderConfig(); } @Override