mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
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:
committed by
GitHub
parent
83994c4a5c
commit
a1c66829db
2
.github/workflows/js-ci.yml
vendored
2
.github/workflows/js-ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
40
js/apps/admin-ui/test/identity-providers/kubernetes.spec.ts
Normal file
40
js/apps/admin-ui/test/identity-providers/kubernetes.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user