8000 Resolve Kubernetes identity provider issuer from discovery by chance-coleman · Pull Request #49600 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ A Kubernetes identity provider supports authenticating clients with Kubernetes s
This feature can be enabled and disabled with the feature `kubernetes-service-accounts` which is enabled by default. It depends on the feature `client-auth-federated` which is enabled by default as well.

The default issuer URL for a Kubernetes cluster is `\https://kubernetes.default.svc.cluster.local`. You can discover
this value by decoding a service account token to retrieve the value of the `iss` claim.
this value by decoding a service account token to retrieve the value of the `iss` claim. Managed Kubernetes clusters
can expose the discovery endpoint through the in-cluster Kubernetes API while issuing service account tokens with a
different issuer URL. In this case, enable automatic issuer discovery. {project_name} resolves the issuer from the
in-cluster discovery endpoint and stores the discovered issuer for client assertion validation.

Keycloak must be able to invoke the endpoint `<ISSUER>/.well-known/openid-configuration` and additionally the
JWKS endpoint returned in the well-known configuration. By default, these endpoints require authentication with a
Expand Down Expand Up @@ -39,7 +42,10 @@ and make sure tokens can not be intercepted in communication between the client
|The alias for the identity provider is used to link a client to the provider

|Kubernetes Issuer URL
|The issuer URL of service account tokens. The URL `<ISSUER>.well-known/openid-configuration` must be available to {project_name})
|The issuer URL of service account tokens. The URL `<ISSUER>/.well-known/openid-configuration` must be available to {project_name} when automatic issuer discovery is disabled

|Automatic issuer discovery
|Resolve the service account token issuer from the in-cluster Kubernetes discovery endpoint. Use this option when a managed cluster exposes discovery through the Kubernetes API but uses a different issuer in service account tokens

|===
+
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -969,8 +969,10 @@ addKubernetesProvider=Add Kubernetes provider
spiffeTrustDomain=SPIFFE Trust Domain
spiffeTrustDomainHelp=Use a URL starting with 'spiffe://' followed by a domain name. For example, 'spiffe://acme.com'.
spiffeBundleEndpoint=SPIFFE Bundle or OIDC JWKs endpoint
kubernetesIssuerUrlHelp=The issuer of the Kubernetes service account tokens
kubernetesIssuerUrlHelp=The issuer of the Kubernetes service account tokens. Disable automatic issuer discovery to edit this value manually.
kubernetesIssuerUrl=Kubernetes Issuer URL
automaticIssuerDiscovery=Automatic issuer discovery
automaticIssuerDiscoveryHelp=Resolve the Kubernetes service account token issuer from the in-cluster Kubernetes discovery endpoint. When enabled, Keycloak uses the default Kubernetes discovery URL and stores the discovered issuer.
permission=Permission
saveEventListeners=Save Event Listeners
capabilityConfig=Capability config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export default function AddKubernetesConnect() {
const id = "kubernetes";

const form = useForm<DiscoveryIdentityProvider>({
defaultValues: { alias: id, config: { allowCreate: "true" } },
defaultValues: {
alias: id,
config: { allowCreate: "true", automaticIssuerDiscovery: "false" },
},
mode: "onChange",
});
const { handleSubmit } = form;
Expand Down
33 changes: 31 additions & 2 deletio 10000 ns js/apps/admin-ui/src/identity-providers/add/KubernetesSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
import { TextControl } from "@keycloak/keycloak-ui-shared";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FormGroupField } from "../component/FormGroupField";
import { Controller, useFormContext } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { TimeSelector } from "../../components/time-selector/TimeSelector";
import IdentityProviderRepresentation from "libs/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import { DefaultSwitchControl } from "../../components/SwitchControl";

const DEFAULT_KUBERNETES_ISSUER_URL =
"https://kubernetes.default.svc.cluster.local";

export const KubernetesSettings = () => {
const { t } = useTranslation();
const { control } = useFormContext<IdentityProviderRepresentation>();
const { control, setValue } =
useFormContext<IdentityProviderRepresentation>();
const automaticIssuerDiscovery = useWatch({
control,
name: "config.automaticIssuerDiscovery",
});

useEffect(() => {
if (automaticIssuerDiscovery === "true") {
setValue("config.issuerDiscoveryUrl", DEFAULT_KUBERNETES_ISSUER_URL, {
shouldDirty: false,
});
} else {
setValue("config.issuerDiscoveryUrl", "", { shouldDirty: false });
}
}, [automaticIssuerDiscovery, setValue]);

return (
<>
<TextControl
Expand All @@ -19,10 +40,18 @@ export const KubernetesSettings = () => {
}}
/>

<DefaultSwitchControl
name="config.automaticIssuerDiscovery"
label={t("automaticIssuerDiscovery")}
labelIcon={t("automaticIssuerDiscoveryHelp")}
stringify
/>

<TextControl
name="config.issuer"
labelIcon={t("kubernetesIssuerUrlHelp")}
label={t("kubernetesIssuerUrl")}
isDisabled={automaticIssuerDiscovery === "true"}
/>
<FormGroupField label="fedClientAssertionMaxExp">
<Controller
Expand Down
15 changes: 15 additions & 0 deletions js/apps/admin-ui/test/identity-providers/kubernetes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ test.describe.serial("Kubernetes identity provider test", () => {
await goToIdentityProviders(page);
await clickTableRowItem(page, "kubernetes");

await expect(
page.getByTestId("config.automaticIssuerDiscovery"),
).not.toBeChecked();
await expect(page.getByTestId("config.issuer")).toBeEnabled();

await page
.getByTestId("config.automaticIssuerDiscovery")
.click({ force: true });
await expect(page.getByTestId("config.issuer")).toBeDisabled();

await page
.getByTestId("config.automaticIssuerDiscovery")
.click({ force: true });
await expect(page.getByTestId("config.issuer")).toBeEnabled();

await page
.getByTestId("config.issuer")
.fill("https://kubernetes2.myorg.com");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public interface KubernetesConstants {
String KUBERNETES_SERVICE_HOST_KEY = "KUBERNETES_SERVICE_HOST";
String KUBERNETES_SERVICE_PORT_HTTPS_KEY = "KUBERNETES_SERVICE_PORT_HTTPS";
String SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
String DEFAULT_KUBERNETES_ISSUER_URL = "https://kubernetes.default.svc.cluster.local";

}
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
package org.keycloak.broker.kubernetes;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

import org.keycloak.broker.oidc.IssuerValidation;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.http.simple.SimpleHttpRequest;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.util.Strings;
import org.keycloak.utils.KeycloakSessionUtil;

import org.apache.http.client.config.RequestConfig;
import org.jboss.logging.Logger;

import static org.keycloak.broker.kubernetes.KubernetesConstants.DEFAULT_KUBERNETES_ISSUER_URL;
import static org.keycloak.broker.kubernetes.KubernetesConstants.KUBERNETES_SERVICE_HOST_KEY;
import static org.keycloak.broker.kubernetes.KubernetesConstants.KUBERNETES_SERVICE_PORT_HTTPS_KEY;
import static org.keycloak.broker.kubernetes.KubernetesConstants.SERVICE_ACCOUNT_TOKEN_PATH;


public class KubernetesIdentityProviderConfig extends IdentityProviderModel implements IssuerValidation {

private static final int DISCOVERY_TIMEOUT_MILLIS = 5000;
private static final Logger logger = Logger.getLogger(KubernetesIdentityProviderConfig.class);
static final String AUTOMATIC_ISSUER_DISCOVERY = "automaticIssuerDiscovery";
static final String ISSUER_DISCOVERY_URL = "issuerDiscoveryUrl";

public KubernetesIdentityProviderConfig() {
}

Expand All @@ -20,6 +43,10 @@ public String getIssuer() {
return getConfig().get(ISSUER);
}

public boolean isAutomaticIssuerDiscovery() {
return Boolean.parseBoolean(getConfig().get(AUTOMATIC_ISSUER_DISCOVERY));
}

public int getAllowedClockSkew() {
String allowedClockSkew = getConfig().get(ALLOWED_CLOCK_SKEW);
if (allowedClockSkew == null || allowedClockSkew.isEmpty()) {
Expand All @@ -41,6 +68,105 @@ public Boolean isHideOnLogin() {
@Override
public void validate(RealmModel realm) {
super.validate(realm);
// Persist the discovered service account token issuer before issuer uniqueness validation.
resolveIssuer();
validateIssuer(realm, IdentityProviderType.CLIENT_ASSERTION);
}

private void resolveIssuer() {
if (!isAutomaticIssuerDiscovery()) {
return;
}

String issuer = getIssuerDiscoveryUrl();
getConfig().remove(ISSUER);
if (!isInClusterDiscoveryUrl(issuer)) {
throw new IllegalArgumentException("Automatic issuer discovery URL must be an HTTPS Kubernetes API service URL");
}

try {
KeycloakSession session = KeycloakSessionUtil.getKeycloakSession();
SimpleHttpRequest request = SimpleHttp.create(session)
.withRequestConfig(RequestConfig.custom()
.setConnectTimeout(DISCOVERY_TIMEOUT_MILLIS)
.setSocketTimeout(DISCOVERY_TIMEOUT_MILLIS)
.build())
.doGet(discoveryUrl(issuer))
.acceptJson();

String token = getServiceAccountToken();
if (!Strings.isEmpty(token)) {
request.auth(token);
}

OIDCConfigurationRepresentation oidcConfig = request.asJson(OIDCConfigurationRepresentation.class);

if (!Strings.isEmpty(oidcConfig.getIssuer())) {
getConfig().put(ISSUER, oidcConfig.getIssuer());
}
} catch (Exception e) {
logger.warnf(e, "Failed to resolve Kubernetes issuer from '%s'", issuer);
}
}

private String getIssuerDiscoveryUrl() {
String issuerDiscoveryUrl = getConfig().get(ISSUER_DISCOVERY_URL);
if (!Strings.isEmpty(issuerDiscoveryUrl)) {
return issuerDiscoveryUrl;
}

return DEFAULT_KUBERNETES_ISSUER_URL;
}

private String discoveryUrl(String issuer) {
int end = issuer.length();
while (end > 0 && issuer.charAt(end - 1) == '/') {
end--;
}
return issuer.substring(0, end) + "/.well-known/openid-configuration";
}

private String getServiceAccountToken() throws Exception {
Path path = Path.of(SERVICE_ACCOUNT_TOKEN_PATH);
if (!Files.exists(path)) {
return null;
}

return Files.readString(path, StandardCharsets.UTF_8);
}

private boolean isInClusterDiscoveryUrl(String issuer) {
URI uri;
try {
uri = URI.create(issuer);
} catch (IllegalArgumentException e) {
return false;
}

if (!"https".equals(uri.getScheme())) {
return false;
}

String host = uri.getHost();
if (host == null) {
return false;
}

// Save-time discovery has no client assertion JWT yet, so it cannot use the runtime
// jwt.issuer comparison from KubernetesJwksEndpointLoader. Limit discovery to the
// in-cluster Kubernetes API service instead.
if ("kubernetes".equals(host) || "kubernetes.default".equals(host) || "kubernetes.default.svc".equals(host) || "kubernetes.default.svc.cluster.local".equals(host)) {
return true;
}

String serviceHost = System.getenv(KUBERNETES_SERVICE_HOST_KEY);
if (!host.equals(serviceHost)) {
return false;
}

String servicePort = System.getenv(KUBERNETES_SERVICE_PORT_HTTPS_KEY);
int port = uri.getPort();
// No explicit port means HTTPS default port, which is the Kubernetes API service port.
return port == -1 || Strings.isEmpty(servicePort) || servicePort.equals(Integer.toString(port));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.Strings;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
Expand All @@ -33,22 +34,29 @@
*/
public class OAuthIdentityProvider {

private static final String IDP_PATH = "/idp";

private final HttpServer httpServer;

private final OAuthIdentityProviderKeys keys;
private final OAuthIdentityProviderConfigBuilder.OAuthIdentityProviderConfiguration config;
private final String issuer;

private int keysRequestCount = 0;

public OAuthIdentityProvider(HttpServer httpServer, OAuthIdentityProviderConfigBuilder.OAuthIdentityProviderConfiguration config) {
this.config = config;
this.issuer = config.issuer();
if (!CryptoIntegration.isInitialised()) {
CryptoIntegration.setProvider(new DefaultCryptoProvider());
}

this.httpServer = httpServer;
httpServer.createContext("/idp/.well-known/openid-configuration", new WellKnownHandler());
httpServer.createContext("/idp/jwks", new JwksHttpHandler());
httpServer.createContext(IDP_PATH + "/.well-known/openid-configuration", new WellKnownHandler());
if (!IDP_PATH.equals(config.discoveryPath())) {
httpServer.createContext(config.discoveryPath() + "/.well-known/openid-configuration", new WellKnownHandler());
}
httpServer.createContext(IDP_PATH + "/jwks", new JwksHttpHandler());

keys = new OAuthIdentityProviderKeys(config);
}
Expand Down Expand Up @@ -78,15 +86,21 @@ public int getKeysRequestCount() {
}

public void close() {
httpServer.removeContext("/idp/.well-known/openid-configuration");
httpServer.removeContext("/idp/jwks");
httpServer.removeContext(IDP_PATH + "/.well-known/openid-configuration");
if (!IDP_PATH.equals(config.discoveryPath())) {
httpServer.removeContext(config.discoveryPath() + "/.well-known/openid-configuration");
}
httpServer.removeContext(IDP_PATH + "/jwks");
}

public class WellKnownHandler implements HttpHandler {

@Override
public void handle(HttpExchange exchange) throws IOException {
OIDCConfigurationRepresentation oidcConfig = new OIDCConfigurationRepresentation();
if (!Strings.isEmpty(issuer)) {
oidcConfig.setIssuer(issuer);
}
oidcConfig.setJwksUri("http://127.0.0.1:8500/idp/jwks");
String oidcConfigString = JsonSerialization.writeValueAsString(oidcConfig);

Expand Down F413
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ public class OAuthIdentityProviderConfigBuilder {

private Mode mode = Mode.DEFAULT;
private boolean jwkUse = true;
private String issuer;
private String discoveryPath = "/idp";

public OAuthIdentityProviderConfigBuilder spiffe() {
mode = Mode.SPIFFE;
Expand All @@ -20,11 +22,21 @@ public OAuthIdentityProviderConfigBuilder jwkUse(boolean jwkUse) {
return this;
}

public OAuthIdentityProviderConfigBuilder issuer(String issuer) {
this.issuer = issuer;
return this;
}

public OAuthIdentityProviderConfigBuilder discoveryPath(String discoveryPath) {
this.discoveryPath = discoveryPath;
return this;
}

public OAuthIdentityProviderConfiguration build() {
return new OAuthIdentityProviderConfiguration(mode, jwkUse);
return new OAuthIdentityProviderConfiguration(mode, jwkUse, issuer, discoveryPath);
}

public record OAuthIdentityProviderConfiguration(Mode mode, boolean jwkUse) {
public record OAuthIdentityProviderConfiguration(Mode mode, boolean jwkUse, String issuer, String discoveryPath) {
}

public enum Mode {
Expand Down
Loading
Loading
0