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