Kubernetes Signed JWT Authenticator UI (#42853)

* Kubernetes Signed JWT Authenticator UI

Signed-off-by: Sebastian Łaskawiec <sebastian.laskawiec@defenseunicorns.com>

* Test and lint fix

Signed-off-by: Sebastian Łaskawiec <sebastian.laskawiec@defenseunicorns.com>

* Optimized imports

Signed-off-by: Sebastian Łaskawiec <sebastian.laskawiec@defenseunicorns.com>

---------

Signed-off-by: Sebastian Łaskawiec <sebastian.laskawiec@defenseunicorns.com>
This commit is contained in:
Sebastian Łaskawiec
2025-09-25 12:15:00 +02:00
committed by GitHub
parent 83994c4a5c
commit a1c66829db
13 changed files with 247 additions and 9 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<DiscoveryIdentityProvider>({
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 (
<>
<ViewHeader titleKey={t("addKubernetesProvider")} />
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(onSubmit)}
>
<KubernetesSettings />
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="createProvider"
>
{t("add")}
</Button>
<Button
variant="link"
data-testid="cancel"
component={(props) => (
<Link {...props} to={toIdentityProviders({ realm })} />
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
}

View File

@@ -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: (
<FormAccess
role="manage-identity-providers"
@@ -503,6 +505,20 @@ export default function DetailSettings() {
</Form>
),
},
{
title: t("generalSettings"),
isHidden: !isKubernetes,
panel: (
<Form
isHorizontal
className="pf-v5-u-py-lg"
onSubmit={handleSubmit(save)}
>
<KubernetesSettings />
<FixedButtonsGroup name="idp-details" isSubmit reset={reset} />
</Form>
),
},
{
title: t("samlSettings"),
isHidden: !isSAML,
@@ -523,7 +539,7 @@ export default function DetailSettings() {
},
{
title: t("advancedSettings"),
isHidden: isSPIFFE,
isHidden: isSPIFFE || isKubernetes,
panel: (
<FormAccess
role="manage-identity-providers"
@@ -575,7 +591,7 @@ export default function DetailSettings() {
</Tab>
<Tab
id="mappers"
isHidden={isSPIFFE}
isHidden={isSPIFFE || isKubernetes}
data-testid="mappers-tab"
title={<TabTitleText>{t("mappers")}</TabTitleText>}
{...mappersTab}

View File

@@ -0,0 +1,25 @@
import { TextControl } from "@keycloak/keycloak-ui-shared";
import { useTranslation } from "react-i18next";
export const KubernetesSettings = () => {
const { t } = useTranslation();
return (
<>
<TextControl
name="alias"
label={t("alias")}
labelIcon={t("aliasHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.jwksUrl"
labelIcon={t("kubernetesJWKSURLHelp")}
label={t("kubernetesJWKSURL")}
/>
</>
);
};

View File

@@ -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,

View File

@@ -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: <AddKubernetesConnect />,
breadcrumb: (t) => t("addKubernetesProvider"),
handle: {
access: "manage-identity-providers",
},
};
export const toIdentityProviderKubernetes = (
params: IdentityProviderKubernetesParams,
): Partial<Path> => ({
pathname: generateEncodedPath(IdentityProviderKubernetesRoute.path, params),
});

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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,
],
{

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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