diff --git a/pom.xml b/pom.xml index da53e99..5667e5d 100644 --- a/pom.xml +++ b/pom.xml @@ -89,6 +89,20 @@ pgv-java-stub 1.0.4 + + + com.nimbusds + oauth2-oidc-sdk + 11.13 + provided + + + + io.quarkus + quarkus-core + 3.12.3 + provided + org.apache.tomcat annotations-api @@ -101,6 +115,12 @@ 5.10.2 test + + org.mockito + mockito-inline + 5.2.0 + test + org.jboss.weld @@ -108,7 +128,6 @@ 4.0.3.Final test - @@ -220,7 +239,7 @@ - + 18645E7B38C5CE94CC4183040B71CD10C1E47F3F diff --git a/src/main/java/org/project_kessel/client/Config.java b/src/main/java/org/project_kessel/client/Config.java deleted file mode 100644 index 70d2075..0000000 --- a/src/main/java/org/project_kessel/client/Config.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.project_kessel.client; - -import io.smallrye.config.ConfigMapping; -import io.smallrye.config.WithDefault; - -/** - * Interface for injecting config into container managed beans. - * It has the current limitation that only one underlying grpc connection can be configured. - * Does nothing if this client is not being managed by a container. - * Works directly for Quarkus. Might need an implementation class for future Spring Boot config. - */ -@ConfigMapping(prefix = "inventory-api") -public interface Config { - @WithDefault("false") - boolean isSecureClients(); - String targetUrl(); -} diff --git a/src/main/java/org/project_kessel/client/CDIManagedClients.java b/src/main/java/org/project_kessel/inventory/client/CDIManagedClients.java similarity index 79% rename from src/main/java/org/project_kessel/client/CDIManagedClients.java rename to src/main/java/org/project_kessel/inventory/client/CDIManagedClients.java index 4c6918d..8f02ca3 100644 --- a/src/main/java/org/project_kessel/client/CDIManagedClients.java +++ b/src/main/java/org/project_kessel/inventory/client/CDIManagedClients.java @@ -1,4 +1,4 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; @@ -15,11 +15,18 @@ public class CDIManagedClients { InventoryGrpcClientsManager getManager(Config config) { var isSecureClients = config.isSecureClients(); var targetUrl = config.targetUrl(); - + var authnEnabled = config.authenticationConfig().map(t -> !t.mode().equals(Config.AuthMode.DISABLED)).orElse(false); if (isSecureClients) { + if(authnEnabled) { + return InventoryGrpcClientsManager.forSecureClients(targetUrl, config.authenticationConfig().get()); + } return InventoryGrpcClientsManager.forSecureClients(targetUrl); } + if(authnEnabled) { + return InventoryGrpcClientsManager.forInsecureClients(targetUrl, config.authenticationConfig().get()); + } + return InventoryGrpcClientsManager.forInsecureClients(targetUrl); } diff --git a/src/main/java/org/project_kessel/inventory/client/Config.java b/src/main/java/org/project_kessel/inventory/client/Config.java new file mode 100644 index 0000000..ba09e9f --- /dev/null +++ b/src/main/java/org/project_kessel/inventory/client/Config.java @@ -0,0 +1,45 @@ +package org.project_kessel.inventory.client; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; + +import java.util.Optional; + +/** + * Interface for injecting config into container managed beans. + * It has the current limitation that only one underlying grpc connection can be configured. + * Does nothing if this client is not being managed by a container. + * Works directly for Quarkus. Might need an implementation class for future Spring Boot config. + */ +@ConfigMapping(prefix = "inventory-api") +public interface Config { + enum AuthMode { + DISABLED, + OIDC_CLIENT_CREDENTIALS + } + + @WithDefault("false") + boolean isSecureClients(); + String targetUrl(); + + @WithName("authn") + Optional authenticationConfig(); + + interface AuthenticationConfig { + @WithDefault("disabled") + AuthMode mode(); + @WithName("client") + Optional clientCredentialsConfig(); + } + + interface OIDCClientCredentialsConfig { + String issuer(); + @WithName("id") + String clientId(); + @WithName("secret") + String clientSecret(); + Optional scope(); + Optional oidcClientCredentialsMinterImplementation(); + } +} diff --git a/src/main/java/org/project_kessel/client/InventoryGrpcClientsManager.java b/src/main/java/org/project_kessel/inventory/client/InventoryGrpcClientsManager.java similarity index 61% rename from src/main/java/org/project_kessel/client/InventoryGrpcClientsManager.java rename to src/main/java/org/project_kessel/inventory/client/InventoryGrpcClientsManager.java index b65cc14..4b5251e 100644 --- a/src/main/java/org/project_kessel/client/InventoryGrpcClientsManager.java +++ b/src/main/java/org/project_kessel/inventory/client/InventoryGrpcClientsManager.java @@ -1,6 +1,7 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import io.grpc.*; +import org.project_kessel.inventory.client.authn.CallCredentialsFactory; import java.util.HashMap; @@ -18,6 +19,20 @@ public static synchronized InventoryGrpcClientsManager forInsecureClients(String return insecureManagers.get(targetUrl); } + public static synchronized InventoryGrpcClientsManager forInsecureClients(String targetUrl, Config.AuthenticationConfig authnConfig) throws RuntimeException { + if (!insecureManagers.containsKey(targetUrl)) { + try { + var manager = new InventoryGrpcClientsManager(targetUrl, + InsecureChannelCredentials.create(), + CallCredentialsFactory.create(authnConfig)); + insecureManagers.put(targetUrl, manager); + } catch (CallCredentialsFactory.CallCredentialsCreationException e) { + throw new RuntimeException(e); + } + } + return insecureManagers.get(targetUrl); + } + public static synchronized InventoryGrpcClientsManager forSecureClients(String targetUrl) { if (!secureManagers.containsKey(targetUrl)) { var tlsChannelCredentials = TlsChannelCredentials.create(); @@ -27,6 +42,21 @@ public static synchronized InventoryGrpcClientsManager forSecureClients(String t return secureManagers.get(targetUrl); } + public static synchronized InventoryGrpcClientsManager forSecureClients(String targetUrl, Config.AuthenticationConfig authnConfig) { + if (!secureManagers.containsKey(targetUrl)) { + var tlsChannelCredentials = TlsChannelCredentials.create(); + try { + var manager = new InventoryGrpcClientsManager(targetUrl, + tlsChannelCredentials, + CallCredentialsFactory.create(authnConfig)); + secureManagers.put(targetUrl, manager); + } catch (CallCredentialsFactory.CallCredentialsCreationException e) { + throw new RuntimeException(e); + } + } + return secureManagers.get(targetUrl); + } + public static synchronized void shutdownAll() { for (var manager : insecureManagers.values()) { @@ -70,6 +100,18 @@ private InventoryGrpcClientsManager(String targetUrl, ChannelCredentials credent this.channel = Grpc.newChannelBuilder(targetUrl, credentials).build(); } + /** + * Create a manager for a grpc channel with server credentials and credentials for per-rpc client authentication. + * @param targetUrl + * @param serverCredentials authenticates the server for TLS or are InsecureChannelCredentials + * @param authnCredentials authenticates the client on each rpc + */ + private InventoryGrpcClientsManager(String targetUrl, ChannelCredentials serverCredentials, CallCredentials authnCredentials) { + this.channel = Grpc.newChannelBuilder(targetUrl, + CompositeChannelCredentials.create(serverCredentials, authnCredentials)).build(); + } + + private void closeClientChannel() { channel.shutdown(); } diff --git a/src/main/java/org/project_kessel/client/InventoryHealthClient.java b/src/main/java/org/project_kessel/inventory/client/InventoryHealthClient.java similarity index 94% rename from src/main/java/org/project_kessel/client/InventoryHealthClient.java rename to src/main/java/org/project_kessel/inventory/client/InventoryHealthClient.java index c947ec1..bea6744 100644 --- a/src/main/java/org/project_kessel/client/InventoryHealthClient.java +++ b/src/main/java/org/project_kessel/inventory/client/InventoryHealthClient.java @@ -1,8 +1,9 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import io.grpc.Channel; import org.project_kessel.api.inventory.v1.*; + import java.util.logging.Logger; public class InventoryHealthClient { diff --git a/src/main/java/org/project_kessel/client/K8sClustersClient.java b/src/main/java/org/project_kessel/inventory/client/K8sClustersClient.java similarity index 98% rename from src/main/java/org/project_kessel/client/K8sClustersClient.java rename to src/main/java/org/project_kessel/inventory/client/K8sClustersClient.java index bf38a97..b8b4d6a 100644 --- a/src/main/java/org/project_kessel/client/K8sClustersClient.java +++ b/src/main/java/org/project_kessel/inventory/client/K8sClustersClient.java @@ -1,4 +1,4 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import io.grpc.Channel; import io.grpc.stub.StreamObserver; @@ -6,6 +6,7 @@ import io.smallrye.mutiny.operators.multi.processors.UnicastProcessor; import org.project_kessel.api.inventory.v1beta1.*; + import java.util.logging.Logger; public class K8sClustersClient { diff --git a/src/main/java/org/project_kessel/client/NotificationsIntegrationClient.java b/src/main/java/org/project_kessel/inventory/client/NotificationsIntegrationClient.java similarity index 99% rename from src/main/java/org/project_kessel/client/NotificationsIntegrationClient.java rename to src/main/java/org/project_kessel/inventory/client/NotificationsIntegrationClient.java index 44aefe4..976a6e4 100644 --- a/src/main/java/org/project_kessel/client/NotificationsIntegrationClient.java +++ b/src/main/java/org/project_kessel/inventory/client/NotificationsIntegrationClient.java @@ -1,4 +1,4 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import io.grpc.Channel; import io.grpc.stub.StreamObserver; @@ -6,6 +6,7 @@ import io.smallrye.mutiny.operators.multi.processors.UnicastProcessor; import org.project_kessel.api.inventory.v1beta1.*; + import java.util.logging.Logger; public class NotificationsIntegrationClient { diff --git a/src/main/java/org/project_kessel/client/PoliciesClient.java b/src/main/java/org/project_kessel/inventory/client/PoliciesClient.java similarity index 98% rename from src/main/java/org/project_kessel/client/PoliciesClient.java rename to src/main/java/org/project_kessel/inventory/client/PoliciesClient.java index 261aeb2..46bb932 100644 --- a/src/main/java/org/project_kessel/client/PoliciesClient.java +++ b/src/main/java/org/project_kessel/inventory/client/PoliciesClient.java @@ -1,4 +1,4 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import io.grpc.Channel; import io.grpc.stub.StreamObserver; diff --git a/src/main/java/org/project_kessel/client/PolicyRelationshipClient.java b/src/main/java/org/project_kessel/inventory/client/PolicyRelationshipClient.java similarity index 99% rename from src/main/java/org/project_kessel/client/PolicyRelationshipClient.java rename to src/main/java/org/project_kessel/inventory/client/PolicyRelationshipClient.java index 37f5335..ba7daca 100644 --- a/src/main/java/org/project_kessel/client/PolicyRelationshipClient.java +++ b/src/main/java/org/project_kessel/inventory/client/PolicyRelationshipClient.java @@ -1,4 +1,4 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import io.grpc.Channel; import io.grpc.stub.StreamObserver; @@ -6,6 +6,7 @@ import io.smallrye.mutiny.operators.multi.processors.UnicastProcessor; import org.project_kessel.api.inventory.v1beta1.*; + import java.util.logging.Logger; public class PolicyRelationshipClient { diff --git a/src/main/java/org/project_kessel/client/RhelHostClient.java b/src/main/java/org/project_kessel/inventory/client/RhelHostClient.java similarity index 97% rename from src/main/java/org/project_kessel/client/RhelHostClient.java rename to src/main/java/org/project_kessel/inventory/client/RhelHostClient.java index b6fc25c..66488e0 100644 --- a/src/main/java/org/project_kessel/client/RhelHostClient.java +++ b/src/main/java/org/project_kessel/inventory/client/RhelHostClient.java @@ -1,4 +1,4 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import io.grpc.Channel; import io.grpc.stub.StreamObserver; @@ -8,6 +8,8 @@ import org.project_kessel.api.inventory.v1beta1.CreateRhelHostResponse; import org.project_kessel.api.inventory.v1beta1.KesselRhelHostServiceGrpc; + + import java.util.logging.Logger; public class RhelHostClient { diff --git a/src/main/java/org/project_kessel/inventory/client/authn/CallCredentialsFactory.java b/src/main/java/org/project_kessel/inventory/client/authn/CallCredentialsFactory.java new file mode 100644 index 0000000..3735d6f --- /dev/null +++ b/src/main/java/org/project_kessel/inventory/client/authn/CallCredentialsFactory.java @@ -0,0 +1,39 @@ +package org.project_kessel.inventory.client.authn; + +import io.grpc.CallCredentials; +import org.project_kessel.inventory.client.Config; +import org.project_kessel.inventory.client.authn.oidc.client.OIDCClientCredentialsCallCredentials; + +public class CallCredentialsFactory { + + private CallCredentialsFactory() { + + } + + public static CallCredentials create(Config.AuthenticationConfig authnConfig) throws CallCredentialsCreationException { + if (authnConfig == null) { + throw new CallCredentialsCreationException("AuthenticationConfig is required to create CallCredentials and must not be null."); + } + + try { + switch (authnConfig.mode()) { + case DISABLED: return null; + case OIDC_CLIENT_CREDENTIALS: return new OIDCClientCredentialsCallCredentials(authnConfig); + } + } catch (OIDCClientCredentialsCallCredentials.OIDCClientCredentialsCallCredentialsException e) { + throw new CallCredentialsCreationException("Failed to create OIDCClientCredentialsCallCredentials.", e); + } + + return null; + } + + public static class CallCredentialsCreationException extends Exception { + public CallCredentialsCreationException(String message) { + super(message); + } + + public CallCredentialsCreationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsCallCredentials.java b/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsCallCredentials.java new file mode 100644 index 0000000..769ed32 --- /dev/null +++ b/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsCallCredentials.java @@ -0,0 +1,95 @@ +package org.project_kessel.inventory.client.authn.oidc.client; + +import io.grpc.Metadata; +import io.grpc.Status; +import org.project_kessel.inventory.client.Config; + +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +public class OIDCClientCredentialsCallCredentials extends io.grpc.CallCredentials { + static final Metadata.Key authorizationKey = Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER); + + private final Config.OIDCClientCredentialsConfig clientCredentialsConfig; + private final OIDCClientCredentialsMinter minter; + + private final AtomicReference storedBearerHeaderRef = new AtomicReference<>(); + + public OIDCClientCredentialsCallCredentials(Config.AuthenticationConfig authnConfig) throws OIDCClientCredentialsCallCredentialsException { + this.clientCredentialsConfig = validateAndExtractConfig(authnConfig); + + Optional minterImpl = clientCredentialsConfig.oidcClientCredentialsMinterImplementation(); + try { + if(minterImpl.isPresent()) { + this.minter = OIDCClientCredentialsMinter.forName(minterImpl.get()); + } else { + this.minter = OIDCClientCredentialsMinter.forDefaultImplementation(); + } + } catch (OIDCClientCredentialsMinter.OIDCClientCredentialsMinterException e) { + throw new OIDCClientCredentialsCallCredentialsException("Couldn't create GrpcCallCredentials because minter impl not instantiated.", e); + } + } + + OIDCClientCredentialsCallCredentials(Config.OIDCClientCredentialsConfig clientCredentialsConfig, OIDCClientCredentialsMinter minter) { + this.clientCredentialsConfig = clientCredentialsConfig; + this.minter = minter; + } + + @Override + public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, MetadataApplier applier) { + appExecutor.execute(() -> { + try { + synchronized (storedBearerHeaderRef) { + if (storedBearerHeaderRef.get() == null || storedBearerHeaderRef.get().isExpired()) { + storedBearerHeaderRef.set(minter.authenticateAndRetrieveAuthorizationHeader(clientCredentialsConfig)); + } + + Metadata headers = new Metadata(); + headers.put(authorizationKey, storedBearerHeaderRef.get().getAuthorizationHeader()); + applier.apply(headers); + } + } catch (Exception e) { + applier.fail(Status.UNAUTHENTICATED.withCause(e)); + } + }); + } + + /** + * For unusual cases where stored credentials (i.e. token), which may be long-lived, is bad and needs to be flushed. + */ + public void flushStoredCredentials() { + synchronized (storedBearerHeaderRef) { + storedBearerHeaderRef.set(null); + } + } + + /* We don't know that smallrye config validation will be used by clients, so do some validation here. */ + static Config.OIDCClientCredentialsConfig validateAndExtractConfig(Config.AuthenticationConfig authnConfig) throws OIDCClientCredentialsCallCredentialsException { + if (authnConfig.clientCredentialsConfig().isEmpty()) { + throw new OIDCClientCredentialsCallCredentialsException("ClientCredentialsConfig is required for OIDC client credentials authentication method."); + } + if(authnConfig.clientCredentialsConfig().get().issuer() == null) { + throw new OIDCClientCredentialsCallCredentialsException("ClientCredentialsConfig Issuer must not be null."); + } + if(authnConfig.clientCredentialsConfig().get().clientId() == null) { + throw new OIDCClientCredentialsCallCredentialsException("ClientCredentialsConfig Client id must not be null."); + } + if(authnConfig.clientCredentialsConfig().get().clientSecret() == null) { + throw new OIDCClientCredentialsCallCredentialsException("ClientCredentialsConfig Client secret must not be null."); + } + + return authnConfig.clientCredentialsConfig().get(); + } + + public static class OIDCClientCredentialsCallCredentialsException extends Exception { + public OIDCClientCredentialsCallCredentialsException(String message) { + super(message); + } + + public OIDCClientCredentialsCallCredentialsException(String message, Throwable cause) { + super(message, cause); + } + } + +} diff --git a/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsMinter.java b/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsMinter.java new file mode 100644 index 0000000..e25645f --- /dev/null +++ b/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsMinter.java @@ -0,0 +1,90 @@ +package org.project_kessel.inventory.client.authn.oidc.client; + +import org.project_kessel.inventory.client.Config; +import org.project_kessel.inventory.client.authn.oidc.client.nimbus.NimbusOIDCClientCredentialsMinter; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.time.LocalDateTime; +import java.util.Optional; + +public abstract class OIDCClientCredentialsMinter { + private static final Class defaultMinter = NimbusOIDCClientCredentialsMinter.class; + + public static OIDCClientCredentialsMinter forDefaultImplementation() throws OIDCClientCredentialsMinterException { + return forClass(defaultMinter); + } + + public static OIDCClientCredentialsMinter forClass(Class minterClass) throws OIDCClientCredentialsMinterException { + try { + Constructor constructor = minterClass.getConstructor(); + return (OIDCClientCredentialsMinter)constructor.newInstance(); + } catch (InvocationTargetException | NoSuchMethodException | InstantiationException | IllegalAccessException e) { + throw new OIDCClientCredentialsMinterException("Can't create instance of OIDC client credentials minter", e); + } + } + + public static OIDCClientCredentialsMinter forName(String name) throws OIDCClientCredentialsMinterException { + try { + Class minterImplClass = Class.forName(name); + return forClass(minterImplClass); + } catch(ClassNotFoundException e) { + throw new OIDCClientCredentialsMinterException("Can't find the specified OIDC client credentials minter implementation", e); + } + } + + public abstract BearerHeader authenticateAndRetrieveAuthorizationHeader(Config.OIDCClientCredentialsConfig clientConfig) throws OIDCClientCredentialsMinterException; + + public static class BearerHeader { + private final String authorizationHeader; + private final Optional expiry; + + public BearerHeader(String authorizationHeader, Optional expiry) { + this.authorizationHeader = authorizationHeader; + this.expiry = expiry; + } + + public String getAuthorizationHeader() { + return authorizationHeader; + } + + public boolean isExpired() { + return expiry.map(t -> t.isBefore(LocalDateTime.now())).orElse(true); + } + } + + /** + * Utility method to derive an expiry dateTime from a just granted token with expires_in set. + * @param expiresIn 0 is expected if expiresIn is not set or otherwise not applicable. + * @return + */ + public static Optional getExpiryDateFromExpiresIn(long expiresIn) { + Optional expiryTime; + if (expiresIn != 0) { + // this processing happens some time after token is granted with lifetime so subtract buffer from lifetime + long bufferSeconds = 60; + if(expiresIn < bufferSeconds) { + expiryTime = Optional.empty(); + } else { + expiryTime = Optional.of(LocalDateTime.now().plusSeconds(expiresIn).minusSeconds(bufferSeconds)); + } + } else { + expiryTime = Optional.empty(); + } + + return expiryTime; + } + + public static Class getDefaultMinterImplementation() { + return defaultMinter; + } + + public static class OIDCClientCredentialsMinterException extends Exception { + public OIDCClientCredentialsMinterException(String message) { + super(message); + } + public OIDCClientCredentialsMinterException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/nimbus/NimbusOIDCClientCredentialsMinter.java b/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/nimbus/NimbusOIDCClientCredentialsMinter.java new file mode 100644 index 0000000..a93bab0 --- /dev/null +++ b/src/main/java/org/project_kessel/inventory/client/authn/oidc/client/nimbus/NimbusOIDCClientCredentialsMinter.java @@ -0,0 +1,69 @@ +package org.project_kessel.inventory.client.authn.oidc.client.nimbus; + +import com.nimbusds.oauth2.sdk.*; +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import io.quarkus.runtime.annotations.RegisterForReflection; +import org.project_kessel.inventory.client.Config; +import org.project_kessel.inventory.client.authn.oidc.client.OIDCClientCredentialsMinter; + +import java.io.IOException; +import java.net.URI; +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * Implementation pulled in by reflection in Vanilla java and registered for reflection if Quarkus native is used. + */ +@RegisterForReflection +public class NimbusOIDCClientCredentialsMinter extends OIDCClientCredentialsMinter { + @Override + public BearerHeader authenticateAndRetrieveAuthorizationHeader(Config.OIDCClientCredentialsConfig config) throws OIDCClientCredentialsMinterException { + Issuer issuer = new Issuer(config.issuer()); + ClientID clientID = new ClientID(config.clientId()); + Secret clientSecret = new Secret(config.clientSecret()); + Optional scope = config.scope().map(Scope::new); + AuthorizationGrant clientGrant = new ClientCredentialsGrant(); + + try { + OIDCProviderMetadata providerMetadata = OIDCProviderMetadata.resolve(issuer); + URI tokenEndpoint = providerMetadata.getTokenEndpointURI(); + ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret); + // Make the token request + TokenRequest request; + if(scope.isPresent()) { + request = new TokenRequest(tokenEndpoint, clientAuth, clientGrant, scope.get()); + } else { + request = new TokenRequest(tokenEndpoint, clientAuth, clientGrant); + } + + TokenResponse tokenResponse = OIDCTokenResponseParser.parse(request.toHTTPRequest().send()); + if (!tokenResponse.indicatesSuccess()) { + TokenErrorResponse errorResponse = tokenResponse.toErrorResponse(); + String code = errorResponse.getErrorObject().getCode(); + String message = errorResponse.getErrorObject().getDescription(); + throw new OIDCClientCredentialsMinterException( + "Error requesting token from endpoint. TokenErrorResponse: code: " + code + ", message: " + message); + } + + OIDCTokenResponse successResponse = (OIDCTokenResponse)tokenResponse.toSuccessResponse(); + BearerAccessToken bearerAccessToken = successResponse.getOIDCTokens().getBearerAccessToken(); + + // Capture expiry if its exists in the token + long lifetime = bearerAccessToken.getLifetime(); + Optional expiryTime = getExpiryDateFromExpiresIn(lifetime); + + return new BearerHeader(bearerAccessToken.toAuthorizationHeader(), expiryTime); + } + catch(IOException | GeneralException e) { + throw new OIDCClientCredentialsMinterException("Failed to retrieve and parse OIDC well-known configuration from provider.", e); + } + } +} diff --git a/src/main/java/org/project_kessel/example/Caller.java b/src/main/java/org/project_kessel/inventory/example/Caller.java similarity index 61% rename from src/main/java/org/project_kessel/example/Caller.java rename to src/main/java/org/project_kessel/inventory/example/Caller.java index b68d154..95da92f 100644 --- a/src/main/java/org/project_kessel/example/Caller.java +++ b/src/main/java/org/project_kessel/inventory/example/Caller.java @@ -1,10 +1,7 @@ -package org.project_kessel.example; +package org.project_kessel.inventory.example; import org.project_kessel.api.inventory.v1.GetLivezRequest; -import org.project_kessel.api.inventory.v1beta1.CreateRhelHostRequest; -import org.project_kessel.api.inventory.v1beta1.Metadata; -import org.project_kessel.api.inventory.v1beta1.RhelHost; -import org.project_kessel.client.InventoryGrpcClientsManager; +import org.project_kessel.inventory.client.InventoryGrpcClientsManager; public class Caller { diff --git a/src/main/proto/kessel/inventory/v1beta1/metadata.proto b/src/main/proto/kessel/inventory/v1beta1/metadata.proto index c97c784..76203f2 100644 --- a/src/main/proto/kessel/inventory/v1beta1/metadata.proto +++ b/src/main/proto/kessel/inventory/v1beta1/metadata.proto @@ -16,7 +16,8 @@ message Metadata { int64 id = 3355 [ (google.api.field_behavior) = OUTPUT_ONLY ]; // The type of the Resource - string resource_type = 442752204; + string resource_type = 442752204 + [ (google.api.field_behavior) = OUTPUT_ONLY ]; // Date and time when the inventory item was first reported. google.protobuf.Timestamp first_reported = 13874816 diff --git a/src/test/java/org/project_kessel/inventory/client/CDIManagedClientsContainerTests.java b/src/test/java/org/project_kessel/inventory/client/CDIManagedClientsContainerTests.java new file mode 100644 index 0000000..2b816e2 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/CDIManagedClientsContainerTests.java @@ -0,0 +1,132 @@ +package org.project_kessel.inventory.client; + +import io.grpc.Server; +import io.grpc.ServerBuilder; +import io.grpc.stub.StreamObserver; +import jakarta.inject.Inject; +import org.jboss.weld.bootstrap.spi.BeanDiscoveryMode; +import org.jboss.weld.environment.se.Weld; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.project_kessel.api.inventory.v1beta1.*; + +import java.io.IOException; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Use Weld as a test container to check CDI functionality. + */ +@EnableWeld +class CDIManagedClientsContainerTests { + @WeldSetup + public WeldInitiator weld = WeldInitiator.from(new Weld().setBeanDiscoveryMode(BeanDiscoveryMode.ALL).addBeanClass(TestConfig.class)).build(); + + private static final int testServerPort = 7000; + + @Inject + RhelHostClient rhelHostClient; + + private static Server grpcServer; + + /* + Start a grpcServer with the following services added and some custom responses that we can check for in the tests. + */ + @BeforeAll + static void setup() throws IOException { + ServerBuilder serverBuilder = ServerBuilder.forPort(testServerPort); + + serverBuilder.addService(new KesselRhelHostServiceGrpc.KesselRhelHostServiceImplBase() { + @Override + public void createRhelHost(CreateRhelHostRequest request, StreamObserver responseObserver) { + responseObserver.onNext(CreateRhelHostResponse.newBuilder() + .setHost(RhelHost.newBuilder() + .setMetadata(Metadata.newBuilder() + .setResourceType("rhel-host") + .setWorkspace("") // Set workspace value as needed + .build()) + .setReporterData(ReporterData.newBuilder() + .setReporterType(ReporterData.ReporterType.REPORTER_TYPE_OCM) + .setReporterInstanceId("user@example.com") + .setReporterVersion("0.1") + .setLocalResourceId("1") + .setApiHref("www.example.com") + .setConsoleHref("www.example.com") + .build()) + .build()) + .build()); + responseObserver.onCompleted(); + } + + }); + + grpcServer = serverBuilder.build(); + grpcServer.start(); + } + + @AfterAll + static void tearDown() { + if (grpcServer != null) { + grpcServer.shutdownNow(); + } + } + + @Test + void basicCDIWiringTest() { + /* Make some calls to dummy services in test grpc server to test injected clients */ + CreateRhelHostRequest request = CreateRhelHostRequest.newBuilder() + .setHost(RhelHost.newBuilder() + .setMetadata(Metadata.newBuilder() + .setResourceType("rhel-host") + .setWorkspace("") // Set workspace value as needed + .build()) + .setReporterData(ReporterData.newBuilder() + .setReporterType(ReporterData.ReporterType.REPORTER_TYPE_OCM) + .setReporterInstanceId("user@example.com") + .setReporterVersion("0.1") + .setLocalResourceId("1") + .setApiHref("www.example.com") + .setConsoleHref("www.example.com") + .build()) + .build()) + .build(); + var rhelHostResponse = rhelHostClient.CreateRhelHost(request); + CreateK8sClusterRequest request1= CreateK8sClusterRequest.newBuilder().setK8SCluster( + K8sCluster.newBuilder().setData(K8sClusterDetail.newBuilder() + .setExternalClusterId("11").setClusterStatus(K8sClusterDetail + .ClusterStatus.CLUSTER_STATUS_READY).build()) + .setMetadata(Metadata.newBuilder() + .setResourceType("rhel-host") + .setWorkspace("") // Set workspace value as needed + .build()).build() + ).build(); + + + assertEquals("rhel-host", rhelHostResponse.getHost().getMetadata().getResourceType()); + } + + /* + Implementation of Config to inject manually with hardcoded settings. + */ + static class TestConfig implements Config { + @Override + public boolean isSecureClients() { + return false; + } + + @Override + public String targetUrl() { + return "0.0.0.0:" + String.valueOf(testServerPort); + } + + @Override + public Optional authenticationConfig() { + return Optional.empty(); + } + } +} diff --git a/src/test/java/org/project_kessel/inventory/client/CDIManagedClientsTest.java b/src/test/java/org/project_kessel/inventory/client/CDIManagedClientsTest.java new file mode 100644 index 0000000..c92d2d1 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/CDIManagedClientsTest.java @@ -0,0 +1,182 @@ +package org.project_kessel.inventory.client; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Optional; + +import static org.mockito.Mockito.*; + +class CDIManagedClientsTest { + @Test + void testInsecureNoAuthnMakesCorrectManagerCall() { + Config config = makeDummyConfig(false, makeDummyAuthenticationConfig(false)); + CDIManagedClients cdiManagedClients = new CDIManagedClients(); + + try (MockedStatic dummyManager = Mockito.mockStatic(InventoryGrpcClientsManager.class)) { + cdiManagedClients.getManager(config); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString()), + times(1) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString(), any()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString(), any()), + times(0) + ); + } + } + + @Test + void testInsecureWithAuthnMakesCorrectManagerCall() { + Config config = makeDummyConfig(false, makeDummyAuthenticationConfig(true)); + CDIManagedClients cdiManagedClients = new CDIManagedClients(); + + try (MockedStatic dummyManager = Mockito.mockStatic(InventoryGrpcClientsManager.class)) { + cdiManagedClients.getManager(config); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString(), any()), + times(1) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString(), any()), + times(0) + ); + } + } + + @Test + void testSecureNoAuthnMakesCorrectManagerCall() { + Config config = makeDummyConfig(true, makeDummyAuthenticationConfig(false)); + CDIManagedClients cdiManagedClients = new CDIManagedClients(); + + try (MockedStatic dummyManager = Mockito.mockStatic(InventoryGrpcClientsManager.class)) { + cdiManagedClients.getManager(config); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString(), any()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString()), + times(1) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString(), any()), + times(0) + ); + } + } + + @Test + void testSecureWithAuthnMakesCorrectManagerCall() { + Config config = makeDummyConfig(true, makeDummyAuthenticationConfig(true)); + CDIManagedClients cdiManagedClients = new CDIManagedClients(); + + try (MockedStatic dummyManager = Mockito.mockStatic(InventoryGrpcClientsManager.class)) { + cdiManagedClients.getManager(config); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forInsecureClients(anyString(), any()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString()), + times(0) + ); + dummyManager.verify( + () -> InventoryGrpcClientsManager.forSecureClients(anyString(), any()), + times(1) + ); + } + } + + static Config makeDummyConfig(boolean secure, Config.AuthenticationConfig authnConfig) { + return new Config() { + @Override + public boolean isSecureClients() { + return secure; + } + + @Override + public String targetUrl() { + return "localhost"; + } + + @Override + public Optional authenticationConfig() { + return Optional.of(authnConfig); + } + }; + } + + static Config.AuthenticationConfig makeDummyAuthenticationConfig(boolean authnEnabled) { + return new Config.AuthenticationConfig() { + @Override + public Config.AuthMode mode() { + if(!authnEnabled) { + return Config.AuthMode.DISABLED; + } + // pick some arbitrary non disabled mode + return Config.AuthMode.OIDC_CLIENT_CREDENTIALS; + } + + @Override + public Optional clientCredentialsConfig() { + if(!authnEnabled) { + return Optional.empty(); + } + + // provide dummy config matching mode, above. + return Optional.of(new Config.OIDCClientCredentialsConfig() { + @Override + public String issuer() { + return ""; + } + + @Override + public String clientId() { + return ""; + } + + @Override + public String clientSecret() { + return ""; + } + + @Override + public Optional scope() { + return Optional.empty(); + } + + @Override + public Optional oidcClientCredentialsMinterImplementation() { + return Optional.empty(); + } + }); + } + }; + } +} diff --git a/src/test/java/org/project_kessel/inventory/client/ConfigTest.java b/src/test/java/org/project_kessel/inventory/client/ConfigTest.java new file mode 100644 index 0000000..7d09551 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/ConfigTest.java @@ -0,0 +1,37 @@ +package org.project_kessel.inventory.client; + +import io.smallrye.config.SmallRyeConfigBuilder; +import io.smallrye.config.common.MapBackedConfigSource; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.fail; + +class ConfigTest { + + @Test + void canLoadBasicConfig() { + /* Should always be able to build a Config from a ConfigSource with just a target url (i.e. minimal config for + * now). Also tests whether the mapping annotations in Config are valid beyond static type checking. */ + try { + new SmallRyeConfigBuilder() + .withSources(new MapBackedConfigSource("test", new HashMap<>(), Integer.MAX_VALUE) { + @Override + public String getValue(String propertyName) { + if ("inventory-api.target-url".equals(propertyName)) { + return "http://localhost:8080"; + } + return null; + } + } + ) + .withMapping(Config.class) + .build(); + } + catch (Exception e) { + fail("Generating a config objective with minimal config should not fail."); + } + } + +} diff --git a/src/test/java/org/project_kessel/client/InventoryGrpcClientsManagerTest.java b/src/test/java/org/project_kessel/inventory/client/InventoryGrpcClientsManagerTest.java similarity index 75% rename from src/test/java/org/project_kessel/client/InventoryGrpcClientsManagerTest.java rename to src/test/java/org/project_kessel/inventory/client/InventoryGrpcClientsManagerTest.java index d805af0..ef36a43 100644 --- a/src/test/java/org/project_kessel/client/InventoryGrpcClientsManagerTest.java +++ b/src/test/java/org/project_kessel/inventory/client/InventoryGrpcClientsManagerTest.java @@ -1,13 +1,13 @@ -package org.project_kessel.client; +package org.project_kessel.inventory.client; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.project_kessel.api.inventory.v1beta1.KesselRhelHostServiceGrpc; import java.util.HashMap; import java.util.Hashtable; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -56,7 +56,7 @@ public void testThreadingChaos() { * creating and destroying managers on different threads. */ try { - Hashtable managers = new Hashtable<>(); + Hashtable managers = new Hashtable<>(); int numberOfThreads = 100; ExecutorService service = Executors.newFixedThreadPool(numberOfThreads); @@ -69,7 +69,7 @@ public void testThreadingChaos() { final int j = i; service.submit(() -> { InventoryGrpcClientsManager manager; - if(j % 2 == 0) { + if (j % 2 == 0) { manager = InventoryGrpcClientsManager.forInsecureClients("localhost" + j); } else { manager = InventoryGrpcClientsManager.forSecureClients("localhost" + j); @@ -97,7 +97,7 @@ public void testThreadingChaos() { final int j = i - numberOfThreads * 2 / 3; service.submit(() -> { InventoryGrpcClientsManager manager; - if(j % 2 == 0) { + if (j % 2 == 0) { manager = InventoryGrpcClientsManager.forInsecureClients("localhost" + j); } else { manager = InventoryGrpcClientsManager.forSecureClients("localhost" + j); @@ -109,7 +109,7 @@ public void testThreadingChaos() { } latch2.await(); latch3.await(); - } catch(Exception e) { + } catch (Exception e) { fail("Should not have thrown any exception"); } } @@ -127,10 +127,51 @@ public void testManagerReuseInternal() throws Exception { insecureField.setAccessible(true); var secureField = InventoryGrpcClientsManager.class.getDeclaredField("secureManagers"); secureField.setAccessible(true); - var insecureManagers = (HashMap)insecureField.get(null); - var secureManagers = (HashMap)secureField.get(null); + var insecureManagers = (HashMap) insecureField.get(null); + var secureManagers = (HashMap) secureField.get(null); assertEquals(2, insecureManagers.size()); assertEquals(2, secureManagers.size()); } + + public static Config.AuthenticationConfig dummyAuthConfigWithGoodOIDCClientCredentials() { + return new Config.AuthenticationConfig() { + @Override + public Config.AuthMode mode() { + return Config.AuthMode.OIDC_CLIENT_CREDENTIALS; // any non-disabled value + } + + @Override + public Optional clientCredentialsConfig() { + return Optional.of(new Config.OIDCClientCredentialsConfig() { + @Override + public String issuer() { + return "http://localhost:8090"; + } + + @Override + public String clientId() { + return "test"; + } + + @Override + public String clientSecret() { + return "test"; + } + + @Override + public Optional scope() { + return Optional.empty(); + } + + @Override + public Optional oidcClientCredentialsMinterImplementation() { + return Optional.empty(); + } + }); + } + }; + } } + + diff --git a/src/test/java/org/project_kessel/inventory/client/authn/CallCredentialsFactoryTest.java b/src/test/java/org/project_kessel/inventory/client/authn/CallCredentialsFactoryTest.java new file mode 100644 index 0000000..cca95f5 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/authn/CallCredentialsFactoryTest.java @@ -0,0 +1,55 @@ +package org.project_kessel.inventory.client.authn; + +import org.junit.jupiter.api.Test; +import org.project_kessel.inventory.client.Config; + + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.fail; +import static org.project_kessel.inventory.client.InventoryGrpcClientsManagerTest.dummyAuthConfigWithGoodOIDCClientCredentials; + + +class CallCredentialsFactoryTest { + + @Test + void testCreateOIDCClientCallCredentials() { + Config.AuthenticationConfig authnConfig = dummyAuthConfigWithGoodOIDCClientCredentials(); + try { + CallCredentialsFactory.create(authnConfig); + } catch (CallCredentialsFactory.CallCredentialsCreationException e) { + fail("CallCredentialsFactory creation for OIDC client should not throw an exception when OIDC client config is good."); + } + } + + @Test + void testFailToCreateCallCredentialsWhenAuthnConfigEmpty() { + Config.AuthenticationConfig authnConfig = null; + try { + CallCredentialsFactory.create(authnConfig); + fail("CallCredentialsFactory creation for OIDC client should throw an exception when OIDC client config is empty."); + } catch (CallCredentialsFactory.CallCredentialsCreationException e) { + } + } + + @Test + void testFailToCreateCallCredentialsForOIDCWhenConfigEmpty() { + Config.AuthenticationConfig authnConfig = new Config.AuthenticationConfig() { + @Override + public Config.AuthMode mode() { + return Config.AuthMode.OIDC_CLIENT_CREDENTIALS; + } + + @Override + public Optional clientCredentialsConfig() { + return Optional.empty(); + } + }; + try { + CallCredentialsFactory.create(authnConfig); + fail("CallCredentialsFactory creation for OIDC client should throw an exception when OIDC client config is empty."); + } catch (CallCredentialsFactory.CallCredentialsCreationException e) { + } + } + +} diff --git a/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsCallCredentialsTest.java b/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsCallCredentialsTest.java new file mode 100644 index 0000000..d07f03c --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsCallCredentialsTest.java @@ -0,0 +1,263 @@ +package org.project_kessel.inventory.client.authn.oidc.client; + +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.netty.shaded.io.netty.util.concurrent.DefaultEventExecutor; +import org.junit.jupiter.api.Test; +import org.project_kessel.inventory.client.Config; + + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.project_kessel.inventory.client.authn.oidc.client.OIDCClientCredentialsMinter.getDefaultMinterImplementation; + + +public class OIDCClientCredentialsCallCredentialsTest { + + @Test + void initializationShouldFailWithNullIssuer() { + try { + var authConfig = makeAuthConfig(null, "some", "some"); + new OIDCClientCredentialsCallCredentials(authConfig); + } + catch (OIDCClientCredentialsCallCredentials.OIDCClientCredentialsCallCredentialsException e) { + return; // expected + } + + fail("Issuer should not be null"); + } + + @Test + void initializationShouldFailWithNullClientId() { + try { + var authConfig = makeAuthConfig("some", null, "some"); + new OIDCClientCredentialsCallCredentials(authConfig); + } + catch (OIDCClientCredentialsCallCredentials.OIDCClientCredentialsCallCredentialsException e) { + return; // expected + } + + fail("Client id should not be null"); + } + + @Test + void initializationShouldFailWithNullClientSecret() { + try { + var authConfig = makeAuthConfig("some", "some", null); + new OIDCClientCredentialsCallCredentials(authConfig); + } + catch (OIDCClientCredentialsCallCredentials.OIDCClientCredentialsCallCredentialsException e) { + return; // expected + } + + fail("Client secret should not be null"); + } + + @Test + void unknownSpecifiedMinterShouldThrowException() { + var authConfig = makeAuthConfig("some", "some", "some", Optional.empty(), + Optional.of("one.bogus.clazz")); + try { + new OIDCClientCredentialsCallCredentials(authConfig); + } + catch (OIDCClientCredentialsCallCredentials.OIDCClientCredentialsCallCredentialsException e) { + return; // expected + } + + fail("Shouldn't be able to instantiate OIDCClientCredentialsCallCredentials with a bogus minter."); + } + + @Test + void knownSpecifiedMinterShouldNotThrowException() { + var authConfig = makeAuthConfig("some", "some", "some", Optional.empty(), + Optional.of(getDefaultMinterImplementation().getName())); + try { + new OIDCClientCredentialsCallCredentials(authConfig); + } + catch (OIDCClientCredentialsCallCredentials.OIDCClientCredentialsCallCredentialsException e) { + fail("Should be able create default minter with no problems."); + } + } + + @Test + void unspecifiedMinterShouldUseDefaultAndNotThrowException() { + var authConfig = makeAuthConfig("some", "some", "some", Optional.empty(), + Optional.empty()); + try { + new OIDCClientCredentialsCallCredentials(authConfig); + } + catch (OIDCClientCredentialsCallCredentials.OIDCClientCredentialsCallCredentialsException e) { + fail("Should be able create default minter with no problems."); + } + } + + @Test + void shouldApplyBearerMetadata() throws InterruptedException { + var authConfig = makeAuthConfig("some", "some", "some", Optional.empty(), + Optional.empty()); + var oidcClientCredentialsConfig = authConfig.clientCredentialsConfig().orElse(null); + var minter = makeFakeMinter(true, 0); + var callCreds = new OIDCClientCredentialsCallCredentials(oidcClientCredentialsConfig, minter); + final AtomicReference metaDataRef = new AtomicReference<>(); + final AtomicReference statusRef = new AtomicReference<>(); + var latch = new CountDownLatch(1); + var metaDataApplier = makeFakeMetadataApplier(metaDataRef, statusRef, latch); + + callCreds.applyRequestMetadata(null, new DefaultEventExecutor(), metaDataApplier); + + latch.await(); + assertEquals("token0", metaDataRef.get().get(OIDCClientCredentialsCallCredentials.authorizationKey)); + assertNull(statusRef.get()); + } + + @Test + void shouldApplyPreviouslyObtainedTokenWhenInLifetime() throws InterruptedException { + var authConfig = makeAuthConfig("some", "some", "some", Optional.empty(), + Optional.empty()); + var oidcClientCredentialsConfig = authConfig.clientCredentialsConfig().orElse(null); + var minter = makeFakeMinter(true, 100000); // big lifetime + var callCreds = new OIDCClientCredentialsCallCredentials(oidcClientCredentialsConfig, minter); + final AtomicReference metaDataRef = new AtomicReference<>(); + final AtomicReference statusRef = new AtomicReference<>(); + var latch = new CountDownLatch(1); + var metaDataApplier = makeFakeMetadataApplier(metaDataRef, statusRef, latch); + + callCreds.applyRequestMetadata(null, new DefaultEventExecutor(), metaDataApplier); + + latch.await(); + var latch2 = new CountDownLatch(1); + var metaDataApplier2 = makeFakeMetadataApplier(metaDataRef, statusRef, latch2); + + callCreds.applyRequestMetadata(null, new DefaultEventExecutor(), metaDataApplier2); + + latch2.await(); + // token0 is the original minted token -- shows there was no second authentication and new token + assertEquals("token0", metaDataRef.get().get(OIDCClientCredentialsCallCredentials.authorizationKey)); + assertNull(statusRef.get()); + } + + @Test + void shouldApplyNewTokenWhenOutOfLifetime() throws InterruptedException { + var authConfig = makeAuthConfig("some", "some", "some", Optional.empty(), + Optional.empty()); + var oidcClientCredentialsConfig = authConfig.clientCredentialsConfig().orElse(null); + var minter = makeFakeMinter(true, 0); // zero lifetime forces new auth token + var callCreds = new OIDCClientCredentialsCallCredentials(oidcClientCredentialsConfig, minter); + final AtomicReference metaDataRef = new AtomicReference<>(); + final AtomicReference statusRef = new AtomicReference<>(); + var latch = new CountDownLatch(1); + var metaDataApplier = makeFakeMetadataApplier(metaDataRef, statusRef, latch); + + callCreds.applyRequestMetadata(null, new DefaultEventExecutor(), metaDataApplier); + + latch.await(); + var latch2 = new CountDownLatch(1); + var metaDataApplier2 = makeFakeMetadataApplier(metaDataRef, statusRef, latch2); + + callCreds.applyRequestMetadata(null, new DefaultEventExecutor(), metaDataApplier2); + + latch2.await(); + // token1 is the second minted token -- shows that when out of lifetime there is a second authn and new token + assertEquals("token1", metaDataRef.get().get(OIDCClientCredentialsCallCredentials.authorizationKey)); + assertNull(statusRef.get()); + } + + @Test + void shouldApplyUnauthenticatedWhenAuthnFails() throws InterruptedException { + var authConfig = makeAuthConfig("some", "some", "some", Optional.empty(), + Optional.empty()); + var oidcClientCredentialsConfig = authConfig.clientCredentialsConfig().orElse(null); + var minter = makeFakeMinter(false, 0); + var callCreds = new OIDCClientCredentialsCallCredentials(oidcClientCredentialsConfig, minter); + final AtomicReference metaDataRef = new AtomicReference<>(); + final AtomicReference statusRef = new AtomicReference<>(); + var latch = new CountDownLatch(1); + var metaDataApplier = makeFakeMetadataApplier(metaDataRef, statusRef, latch); + + callCreds.applyRequestMetadata(null, new DefaultEventExecutor(), metaDataApplier); + + latch.await(); + assertNull(metaDataRef.get()); + assertEquals(Status.Code.UNAUTHENTICATED, statusRef.get().getCode()); + } + + static OIDCClientCredentialsMinter makeFakeMinter(boolean alwaysSucceedsOrFails, long tokensExpireIn) { + return new OIDCClientCredentialsMinter() { + int mintedNumber = 0; + + @Override + public BearerHeader authenticateAndRetrieveAuthorizationHeader(Config.OIDCClientCredentialsConfig clientConfig) throws OIDCClientCredentialsMinterException { + if (!alwaysSucceedsOrFails) { + throw new OIDCClientCredentialsMinterException("Authentication failed."); + } + + Optional expiry = Optional.of(LocalDateTime.now().plusSeconds(tokensExpireIn)); + return new BearerHeader("token" + mintedNumber++, expiry); + } + }; + } + + static CallCredentials.MetadataApplier makeFakeMetadataApplier(AtomicReference metaDataRef, AtomicReference statusRef, CountDownLatch latch) { + return new CallCredentials.MetadataApplier() { + @Override + public void apply(Metadata headers) { + metaDataRef.set(headers); + latch.countDown(); + } + + @Override + public void fail(Status status) { + statusRef.set(status); + latch.countDown(); + } + }; + } + + public static Config.AuthenticationConfig makeAuthConfig(String issuer, String clientId, String clientSecret) { + return makeAuthConfig(issuer, clientId, clientSecret, Optional.empty(), Optional.empty()); + } + + public static Config.AuthenticationConfig makeAuthConfig(String issuer, String clientId, String clientSecret, Optional scope, Optional minterImpl) { + return new Config.AuthenticationConfig() { + @Override + public Config.AuthMode mode() { + return null; + } + + @Override + public Optional clientCredentialsConfig() { + return Optional.of(new Config.OIDCClientCredentialsConfig() { + @Override + public String issuer() { + return issuer; + } + + @Override + public String clientId() { + return clientId; + } + + @Override + public String clientSecret() { + return clientSecret; + } + + @Override + public Optional scope() { + return scope; + } + + @Override + public Optional oidcClientCredentialsMinterImplementation() { + return minterImpl; + } + }); + } + }; + } +} diff --git a/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsMinterTest.java b/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsMinterTest.java new file mode 100644 index 0000000..f095529 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/OIDCClientCredentialsMinterTest.java @@ -0,0 +1,99 @@ +package org.project_kessel.inventory.client.authn.oidc.client; + +import org.junit.jupiter.api.Test; +import org.project_kessel.inventory.client.Config; + + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.project_kessel.inventory.client.authn.oidc.client.OIDCClientCredentialsMinter.getExpiryDateFromExpiresIn; + + +class OIDCClientCredentialsMinterTest { + + @Test + void testCreateDefaultMinter() { + Class defaultMinterClass = OIDCClientCredentialsMinter.getDefaultMinterImplementation(); + try { + var minter = OIDCClientCredentialsMinter.forClass(defaultMinterClass); + assertInstanceOf(defaultMinterClass, minter); + } catch (OIDCClientCredentialsMinter.OIDCClientCredentialsMinterException e) { + fail("Creating minter from default implementation name should not throw an OIDCClientCredentialsMinterException"); + } + } + + @Test + void testCreateMinterFromClass() { + Class testMinterClass = TestMinter.class; + try { + var minter = OIDCClientCredentialsMinter.forClass(testMinterClass); + assertInstanceOf(testMinterClass, minter); + } catch (OIDCClientCredentialsMinter.OIDCClientCredentialsMinterException e) { + fail("Creating minter from test implementation name should not throw an OIDCClientCredentialsMinterException"); + } + } + + @Test + void testCreateMinterFromName() { + String testMinterName = TestMinter.class.getName(); + try { + OIDCClientCredentialsMinter.forName(testMinterName); + } catch (OIDCClientCredentialsMinter.OIDCClientCredentialsMinterException e) { + fail("Creating minter from test implementation name should not throw an OIDCClientCredentialsMinterException"); + } + } + + @Test + void testCreateMinterFromFakeImplNameThrowsException() { + String defaultMinterName = "absolutely.not.a.valid.Implementation"; + try { + OIDCClientCredentialsMinter.forName(defaultMinterName); + } catch (OIDCClientCredentialsMinter.OIDCClientCredentialsMinterException e) { + return; + } + fail("Creating minter from not existent implementation name should throw an OIDCClientCredentialsMinterException"); + } + + @Test + void testGetExpiryDateFromExpiresInLongLived() { + LocalDateTime someTimeBefore = LocalDateTime.now().plusSeconds(9000); + LocalDateTime someTimeAfter = LocalDateTime.now().plusSeconds(11000); + Optional expiryDate = getExpiryDateFromExpiresIn(10000); + assertTrue(expiryDate.isPresent()); + assertTrue(someTimeBefore.isBefore(expiryDate.get())); + assertTrue(someTimeAfter.isAfter(expiryDate.get())); + } + + @Test + void testGetAbsentExpiryDateFromExpiresInShortLived() { + Optional expiryDate = getExpiryDateFromExpiresIn(0); + assertTrue(expiryDate.isEmpty()); + } + + @Test + void bearerHeaderExpiryScenarios() { + Optional someTimeInTheFuture = Optional.of(LocalDateTime.now().plusSeconds(10000)); + var bearerHeader = new OIDCClientCredentialsMinter.BearerHeader("header", someTimeInTheFuture); + assertFalse(bearerHeader.isExpired()); + + Optional someTimeInThePast = Optional.of(LocalDateTime.now().minusSeconds(10000)); + bearerHeader = new OIDCClientCredentialsMinter.BearerHeader("header", someTimeInThePast); + assertTrue(bearerHeader.isExpired()); + + Optional noExpiryTime = Optional.empty(); + bearerHeader = new OIDCClientCredentialsMinter.BearerHeader("header", noExpiryTime); + assertTrue(bearerHeader.isExpired()); + } + + static class TestMinter extends OIDCClientCredentialsMinter { + public TestMinter() { + } + + @Override + public BearerHeader authenticateAndRetrieveAuthorizationHeader(Config.OIDCClientCredentialsConfig clientConfig) throws OIDCClientCredentialsMinterException { + return null; + } + } +} diff --git a/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/nimbus/NimbusOIDCClientCredentialsMinterTest.java b/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/nimbus/NimbusOIDCClientCredentialsMinterTest.java new file mode 100644 index 0000000..5902435 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/authn/oidc/client/nimbus/NimbusOIDCClientCredentialsMinterTest.java @@ -0,0 +1,49 @@ +package org.project_kessel.inventory.client.authn.oidc.client.nimbus; + +import org.junit.jupiter.api.Test; +import org.project_kessel.inventory.client.InventoryGrpcClientsManagerTest; +import org.project_kessel.inventory.client.authn.oidc.client.OIDCClientCredentialsMinter; +import org.project_kessel.inventory.client.fake.FakeIdp; + + +import static org.junit.jupiter.api.Assertions.*; +import static org.project_kessel.inventory.client.InventoryGrpcClientsManagerTest.dummyAuthConfigWithGoodOIDCClientCredentials; + +public class NimbusOIDCClientCredentialsMinterTest { + + @Test + void shouldReturnBearerHeaderWhenIdPAuthenticates() { + var minter = new NimbusOIDCClientCredentialsMinter(); + var config = InventoryGrpcClientsManagerTest.dummyAuthConfigWithGoodOIDCClientCredentials().clientCredentialsConfig(); + OIDCClientCredentialsMinter.BearerHeader bearerHeader = null; + try { + FakeIdp fakeIdp = new FakeIdp(8090); + fakeIdp.start(); + bearerHeader = minter.authenticateAndRetrieveAuthorizationHeader(config.get()); + fakeIdp.stop(); + } catch (OIDCClientCredentialsMinter.OIDCClientCredentialsMinterException e) { + fail("Should not throw exception if authn is successful."); + } + + assertNotNull(bearerHeader); + assertEquals("Bearer blah", bearerHeader.getAuthorizationHeader()); + } + + @Test + void shouldThrowExceptionWhenIdPAuthenticationFails() { + var minter = new NimbusOIDCClientCredentialsMinter(); + var config = InventoryGrpcClientsManagerTest.dummyAuthConfigWithGoodOIDCClientCredentials().clientCredentialsConfig(); + FakeIdp fakeIdp = new FakeIdp(8090, false); + try { + fakeIdp.start(); + minter.authenticateAndRetrieveAuthorizationHeader(config.get()); + fail("Should throw exception if authn is not successful."); + } catch (OIDCClientCredentialsMinter.OIDCClientCredentialsMinterException e) { + // success + } catch(Exception e) { + fail("OIDCClientCredentialsMinterException expected."); + } finally { + fakeIdp.stop(); + } + } +} diff --git a/src/test/java/org/project_kessel/inventory/client/fake/FakeIdp.java b/src/test/java/org/project_kessel/inventory/client/fake/FakeIdp.java new file mode 100644 index 0000000..8febfd0 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/fake/FakeIdp.java @@ -0,0 +1,105 @@ +package org.project_kessel.inventory.client.fake; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; + +/** + * Super-fake Idp that supports a hard-coded well-known discovery endpoint and a corresponding fake token endpoint. + * Does not use TLS. + */ +public class FakeIdp { + private final int port; + private final boolean alwaysSucceedOrFailAuthn; + HttpServer server = null; + + public FakeIdp(int port) { + this(port, true); + } + + public FakeIdp(int port, boolean alwaysSucceedOrFailAuthn) { + this.port = port; + this.alwaysSucceedOrFailAuthn = alwaysSucceedOrFailAuthn; + } + + public void start() { + try { + server = HttpServer.create(new InetSocketAddress(port), 0); + } catch (IOException e) { + throw new RuntimeException(e); + } + server.createContext("/.well-known/openid-configuration", new WellKnownHandler()); + if(alwaysSucceedOrFailAuthn) { + server.createContext("/token", new TokenHandler()); + } else { + server.createContext("/token", new UnauthorizedHandler()); + } + + server.setExecutor(null); // creates a default executor + server.start(); + } + + public void stop() { + server.stop(0); + } + + static class TokenHandler implements HttpHandler { + @Override + public void handle(HttpExchange t) throws IOException { + String response = "{\n" + + " \"iss\": \"http://localhost:8090/\",\n" + + " \"aud\": \"us\",\n" + + " \"sub\": \"usr_123\",\n" + + " \"scope\": \"read write\",\n" + + " \"iat\": 1458785796,\n" + + " \"exp\": 1458872196,\n" + + " \"token_type\": \"Bearer\",\n" + + " \"access_token\": \"blah\"\n" + + "}"; + t.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + t.sendResponseHeaders(200, response.length()); + OutputStream os = t.getResponseBody(); + os.write(response.getBytes()); + os.close(); + } + } + + static class UnauthorizedHandler implements HttpHandler { + @Override + public void handle(HttpExchange t) throws IOException { + String response = "{\"error_description\":\"Access denied by resource owner or authorization server\",\"error\":\"access_denied\"}"; + + // https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint (3.1.3.4. Token Error Response) + t.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + t.sendResponseHeaders(400, response.length()); + OutputStream os = t.getResponseBody(); + os.write(response.getBytes()); + os.close(); + } + } + + static class WellKnownHandler implements HttpHandler { + @Override + public void handle(HttpExchange t) throws IOException { + String response = "{\n" + + "\t\"issuer\":\"http://localhost:8090\",\n" + + "\t\"authorization_endpoint\":\"http://localhost:8090/protocol/openid-connect/auth\",\n" + + "\t\"token_endpoint\":\"http://localhost:8090/token\",\n" + + "\t\"introspection_endpoint\":\"http://localhost:8090/token/introspect\",\n" + + "\t\"jwks_uri\":\"http://localhost:8090/certs\",\n" + + "\t\"response_types_supported\":[\"code\",\"none\",\"id_token\",\"token\",\"id_token token\",\"code id_token\",\"code token\",\"code id_token token\"],\n" + + "\t\"token_endpoint_auth_methods_supported\":[\"private_key_jwt\",\"client_secret_basic\",\"client_secret_post\",\"tls_client_auth\",\"client_secret_jwt\"],\n" + + "\t\"subject_types_supported\":[\"public\",\"pairwise\"]\n" + + "}"; + t.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + t.sendResponseHeaders(200, response.length()); + OutputStream os = t.getResponseBody(); + os.write(response.getBytes()); + os.close(); + } + } +} diff --git a/src/test/java/org/project_kessel/inventory/client/fake/GrpcServerSpy.java b/src/test/java/org/project_kessel/inventory/client/fake/GrpcServerSpy.java new file mode 100644 index 0000000..f73e489 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/fake/GrpcServerSpy.java @@ -0,0 +1,167 @@ +package org.project_kessel.inventory.client.fake; + +import io.grpc.*; +import io.grpc.Metadata; +import io.grpc.stub.StreamObserver; +import org.project_kessel.api.inventory.v1beta1.*; + + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public class GrpcServerSpy extends Server { + private final Server server; + + public GrpcServerSpy(int port, boolean tlsEnabled, ServerInterceptor interceptor, BindableService... services) { + ServerBuilder serverBuilder = ServerBuilder.forPort(port); + if (tlsEnabled) { + URL certsUrl = Thread.currentThread().getContextClassLoader().getResource("certs/test.crt"); + URL keyUrl = Thread.currentThread().getContextClassLoader().getResource("certs/test.key"); + File certFile = new File(Objects.requireNonNull(certsUrl).getPath()); + File keyFile = new File(Objects.requireNonNull(keyUrl).getPath()); + serverBuilder.useTransportSecurity(certFile, keyFile); + } + if (interceptor != null) { + serverBuilder.intercept(interceptor); + } + for (BindableService service : services) { + serverBuilder.addService(service); + } + server = serverBuilder.build(); + } + + public static ServerCallDetails runAgainstTemporaryServerWithDummyServices(int port, Call grpcCallFunction) { + return runAgainstTemporaryServerWithDummyServicesTlsSelect(port, false, grpcCallFunction); + } + + public static ServerCallDetails runAgainstTemporaryTlsServerWithDummyServices(int port, Call grpcCallFunction) { + return runAgainstTemporaryServerWithDummyServicesTlsSelect(port, true, grpcCallFunction); + } + + private static ServerCallDetails runAgainstTemporaryServerWithDummyServicesTlsSelect(int port, boolean tlsEnabled, Call grpcCallFunction) { + var dummyRhelHostService = new KesselRhelHostServiceGrpc.KesselRhelHostServiceImplBase() { + @Override + public void createRhelHost(CreateRhelHostRequest request, StreamObserver responseObserver) { + super.createRhelHost(request, responseObserver); + } + }; + + var dummyNotificationService = new KesselNotificationsIntegrationServiceGrpc.KesselNotificationsIntegrationServiceImplBase() { + + @Override + public void createNotificationsIntegration(CreateNotificationsIntegrationRequest request, StreamObserver responseObserver) { + super.createNotificationsIntegration(request, responseObserver); + } + }; + + return runAgainstTemporaryServerTlsSelect(port, tlsEnabled, grpcCallFunction, dummyRhelHostService, dummyNotificationService); + } + + public static ServerCallDetails runAgainstTemporaryServer(int port, Call grpcCallFunction, BindableService... services) { + return runAgainstTemporaryServerTlsSelect(port, false, grpcCallFunction, services); + } + + public static ServerCallDetails runAgainstTemporaryTlsServer(int port, Call grpcCallFunction, BindableService... services) { + return runAgainstTemporaryServerTlsSelect(port, true, grpcCallFunction, services); + } + + private static ServerCallDetails runAgainstTemporaryServerTlsSelect(int port, boolean tlsEnabled, Call grpcCallFunction, BindableService... services) { + final ServerCallDetails serverCallDetails = new ServerCallDetails(); + + var spyInterceptor = new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall(ServerCall call, Metadata headers, ServerCallHandler next) { + serverCallDetails.setCall(call); + serverCallDetails.setMetadata(headers); + return next.startCall(call, headers); + } + }; + + FakeIdp fakeIdp = new FakeIdp(8090); + var serverSpy = new GrpcServerSpy(port, tlsEnabled, spyInterceptor, services); + + try { + fakeIdp.start(); + serverSpy.start(); + grpcCallFunction.call(); + serverSpy.shutdown(); + fakeIdp.stop(); + + return serverCallDetails; + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + serverSpy.shutdown(); + fakeIdp.stop(); + } + } + + @Override + public Server start() throws IOException { + server.start(); + return this; + } + + @Override + public Server shutdown() { + server.shutdown(); + return this; + } + + @Override + public Server shutdownNow() { + server.shutdownNow(); + return this; + } + + @Override + public boolean isShutdown() { + return server.isShutdown(); + } + + @Override + public boolean isTerminated() { + return server.isTerminated(); + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + return server.awaitTermination(timeout, unit); + } + + @Override + public void awaitTermination() throws InterruptedException { + server.awaitTermination(); + } + + public interface Call { + void call(); + } + + public static class ServerCallDetails { + private ServerCall call; + private Metadata metadata; + + public ServerCallDetails() { + } + + public ServerCall getCall() { + return call; + } + + public Metadata getMetadata() { + return metadata; + } + + public void setCall(ServerCall call) { + this.call = call; + } + + public void setMetadata(Metadata metadata) { + this.metadata = metadata; + } + } +} diff --git a/src/test/java/org/project_kessel/inventory/client/util/CertUtil.java b/src/test/java/org/project_kessel/inventory/client/util/CertUtil.java new file mode 100644 index 0000000..33eb709 --- /dev/null +++ b/src/test/java/org/project_kessel/inventory/client/util/CertUtil.java @@ -0,0 +1,72 @@ +package org.project_kessel.inventory.client.util; + +import java.io.*; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; + +public class CertUtil { + private static final char[] passphrase = "changeit".toCharArray(); + private static final String selfSignedAlias = "selfsigned"; + private static final String certFileName = "certs/test.crt"; + + public static void addTestCACertToTrustStore() { + try { + var keystore = loadKeystoreFromJdk(); + if (keystore.containsAlias(selfSignedAlias)) { + return; + } + + try(InputStream certIn = Thread.currentThread().getContextClassLoader().getResourceAsStream(certFileName); + BufferedInputStream bis = new BufferedInputStream(certIn)) { + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + while (bis.available() > 0) { + Certificate cert = cf.generateCertificate(bis); + keystore.setCertificateEntry(selfSignedAlias, cert); + } + + saveKeystoreToJdk(keystore); + } + } catch (CertificateException | KeyStoreException | IOException | NullPointerException e) { + throw new RuntimeException(e); + } + } + + public static void removeTestCACertFromKeystore() { + var keystore = loadKeystoreFromJdk(); + try { + keystore.deleteEntry(selfSignedAlias); + saveKeystoreToJdk(keystore); + } catch (KeyStoreException e) { + throw new RuntimeException(e); + } + } + + public static KeyStore loadKeystoreFromJdk() { + try (InputStream localCertIn = new FileInputStream(getCertFile())) { + KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); + keystore.load(localCertIn, passphrase); + return keystore; + } catch (IOException | CertificateException | KeyStoreException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + public static void saveKeystoreToJdk(KeyStore keystore) { + try (OutputStream out = new FileOutputStream(getCertFile())) { + keystore.store(out, passphrase); + } catch (IOException | CertificateException | KeyStoreException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private static File getCertFile() { + final char sep = File.separatorChar; + File dir = new File(System.getProperty("java.home") + sep + "lib" + sep + "security"); + return new File(dir, "cacerts"); + } +}