Skip to content

Commit

Permalink
Support Keycloak Dev Services for standalone OIDC Client Registration
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Nov 5, 2024
1 parent 4f49059 commit 6a34154
Show file tree
Hide file tree
Showing 14 changed files with 462 additions and 209 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.quarkus.devservices.keycloak;

import io.quarkus.builder.item.MultiBuildItem;
import io.quarkus.devui.spi.page.CardPageBuildItem;

/**
* Extensions should produce this build item if a DEV UI card with
* the Keycloak Admin link should be created for the extension.
*/
public final class KeycloakAdminPageBuildItem extends MultiBuildItem {

final CardPageBuildItem cardPage;

/**
* @param cardPage created inside extension that requires Keycloak Dev Service, this way, card page
* custom identifier deduced from a stacktrace walker will identify the extension correctly
*/
public KeycloakAdminPageBuildItem(CardPageBuildItem cardPage) {
this.cardPage = cardPage;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package io.quarkus.devservices.keycloak;

import static io.quarkus.devservices.keycloak.KeycloakDevServicesProcessor.KEYCLOAK_URL_KEY;

import java.util.Map;
import java.util.Optional;

import io.quarkus.builder.item.SimpleBuildItem;

Expand Down Expand Up @@ -28,4 +31,11 @@ public Map<String, String> getConfig() {
public boolean isContainerRestarted() {
return containerRestarted;
}

public static String getKeycloakUrl(Optional<KeycloakDevServicesConfigBuildItem> configBuildItem) {
return configBuildItem
.map(KeycloakDevServicesConfigBuildItem::getConfig)
.map(config -> config.get(KEYCLOAK_URL_KEY))
.orElse(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.quarkus.devservices.keycloak;

import java.util.Map;

import org.keycloak.representations.idm.RealmRepresentation;

public interface KeycloakDevServicesConfigurator {

record ConfigPropertiesContext(String authServerInternalUrl, String oidcClientId, String oidcClientSecret) {
}

Map<String, String> createProperties(ConfigPropertiesContext context);

default void customizeDefaultRealm(RealmRepresentation realmRepresentation) {
}

}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package io.quarkus.devservices.keycloak;

import static io.quarkus.devservices.keycloak.KeycloakDevServicesProcessor.OIDC_CLIENT_AUTH_SERVER_URL_CONFIG_KEY;
import static io.quarkus.devservices.keycloak.KeycloakDevServicesProcessor.OIDC_CLIENT_TOKEN_PATH_CONFIG_KEY;
import static java.util.Objects.requireNonNull;

import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.jboss.logging.Logger;
import org.keycloak.representations.idm.RealmRepresentation;

import io.quarkus.builder.item.MultiBuildItem;
import io.quarkus.runtime.configuration.ConfigUtils;
Expand All @@ -15,33 +22,65 @@
*/
public final class KeycloakDevServicesRequiredBuildItem extends MultiBuildItem {

enum Capability {
OIDC,
OIDC_CLIENT
}
private static final Logger LOG = Logger.getLogger(KeycloakDevServicesProcessor.class);
public static final String OIDC_AUTH_SERVER_URL_CONFIG_KEY = "quarkus.oidc.auth-server-url";

private final Capability capability;
private final KeycloakDevServicesConfigurator devServicesConfigurator;
private final String authServerUrl;

private KeycloakDevServicesRequiredBuildItem(Capability capability) {
this.capability = capability;
private KeycloakDevServicesRequiredBuildItem(KeycloakDevServicesConfigurator devServicesConfigurator,
String authServerUrl) {
this.devServicesConfigurator = requireNonNull(devServicesConfigurator);
this.authServerUrl = requireNonNull(authServerUrl);
}

static boolean setOidcConfigProperties(List<KeycloakDevServicesRequiredBuildItem> items) {
return items.stream().anyMatch(i -> i.capability == Capability.OIDC);
String getAuthServerUrl() {
return authServerUrl;
}

static boolean setOidcClientConfigProperties(List<KeycloakDevServicesRequiredBuildItem> items) {
boolean serverUrlOrTokenPathConfigured = ConfigUtils.isPropertyNonEmpty(OIDC_CLIENT_AUTH_SERVER_URL_CONFIG_KEY)
|| ConfigUtils.isPropertyNonEmpty(OIDC_CLIENT_TOKEN_PATH_CONFIG_KEY);
return !serverUrlOrTokenPathConfigured
&& items.stream().anyMatch(i -> i.capability == Capability.OIDC_CLIENT);
public static KeycloakDevServicesRequiredBuildItem of(KeycloakDevServicesConfigurator devServicesConfigurator,
String authServerUrl, String... dontStartConfigProperties) {
if (shouldStartDevService(dontStartConfigProperties, authServerUrl)) {
return new KeycloakDevServicesRequiredBuildItem(devServicesConfigurator, authServerUrl);
}
return null;
}

static KeycloakDevServicesConfigurator getDevServicesConfigurator(List<KeycloakDevServicesRequiredBuildItem> items) {
return new KeycloakDevServicesConfigurator() {
@Override
public Map<String, String> createProperties(ConfigPropertiesContext context) {
return items
.stream()
.map(i -> i.devServicesConfigurator)
.map(producer -> producer.createProperties(context))
.map(Map::entrySet)
.flatMap(Collection::stream)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}

@Override
public void customizeDefaultRealm(RealmRepresentation realmRepresentation) {
items
.stream()
.map(i -> i.devServicesConfigurator)
.forEach(i -> i.customizeDefaultRealm(realmRepresentation));
}
};
}

public static KeycloakDevServicesRequiredBuildItem requireDevServiceForOidc() {
return new KeycloakDevServicesRequiredBuildItem(Capability.OIDC);
private static boolean shouldStartDevService(String[] dontStartConfigProperties, String authServerUrl) {
return Stream
.concat(Stream.of(authServerUrl), Arrays.stream(dontStartConfigProperties))
.allMatch(KeycloakDevServicesRequiredBuildItem::shouldStartDevService);
}

public static KeycloakDevServicesRequiredBuildItem requireDevServiceForOidcClient() {
return new KeycloakDevServicesRequiredBuildItem(Capability.OIDC_CLIENT);
private static boolean shouldStartDevService(String dontStartConfigProperty) {
if (ConfigUtils.isPropertyNonEmpty(dontStartConfigProperty)) {
// this build item does not require to start the Keycloak Dev Service as runtime property was set
LOG.debugf("Not starting Dev Services for Keycloak as '%s' has been provided", dontStartConfigProperty);
return false;
}
return true;
}
}
8 changes: 4 additions & 4 deletions extensions/oidc-client-registration/deployment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc-common-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-devservices-keycloak</artifactId>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down Expand Up @@ -83,10 +87,6 @@
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skip>false</skip>
<systemPropertyVariables>
<keycloak.docker.image>${keycloak.docker.legacy.image}</keycloak.docker.image>
<keycloak.use.https>false</keycloak.use.https>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.quarkus.oidc.client.registration.deployment.devservices.keycloak;

import static io.quarkus.devservices.keycloak.KeycloakDevServicesRequiredBuildItem.OIDC_AUTH_SERVER_URL_CONFIG_KEY;

import java.util.List;
import java.util.Map;

import org.eclipse.microprofile.config.ConfigProvider;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;

import io.quarkus.deployment.IsDevelopment;
import io.quarkus.deployment.IsNormal;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.BuildSteps;
import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig;
import io.quarkus.devservices.keycloak.KeycloakAdminPageBuildItem;
import io.quarkus.devservices.keycloak.KeycloakDevServicesConfigurator;
import io.quarkus.devservices.keycloak.KeycloakDevServicesRequiredBuildItem;
import io.quarkus.devui.spi.page.CardPageBuildItem;
import io.quarkus.oidc.client.registration.deployment.OidcClientRegistrationBuildStep;

@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = { OidcClientRegistrationBuildStep.IsEnabled.class,
GlobalDevServicesConfig.Enabled.class })
public class KeycloakDevServiceRequiredBuildStep {

private static final String OIDC_CLIENT_REG_AUTH_SERVER_URL_CONFIG_KEY = "quarkus.oidc-client-registration.auth-server-url";

@BuildStep
KeycloakDevServicesRequiredBuildItem requireKeycloakDevService() {
var devServicesConfigurator = new KeycloakDevServicesConfigurator() {

@Override
public Map<String, String> createProperties(ConfigPropertiesContext ctx) {
return Map.of(OIDC_CLIENT_REG_AUTH_SERVER_URL_CONFIG_KEY, ctx.authServerInternalUrl());
}

@Override
public void customizeDefaultRealm(RealmRepresentation realmRepresentation) {
if (getInitialToken() == null) {
realmRepresentation.setRegistrationAllowed(true);
realmRepresentation.setRegistrationFlow("registration");
if (realmRepresentation.getComponents() == null) {
realmRepresentation.setComponents(new MultivaluedHashMap<>());
}
var componentExportRepresentation = new ComponentExportRepresentation();
componentExportRepresentation.setName("Full Scope Disabled");
componentExportRepresentation.setProviderId("scope");
componentExportRepresentation.setSubType("anonymous");
realmRepresentation.getComponents().put(
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy",
List.of(componentExportRepresentation));
}
}
};

return KeycloakDevServicesRequiredBuildItem.of(devServicesConfigurator,
OIDC_CLIENT_REG_AUTH_SERVER_URL_CONFIG_KEY, OIDC_AUTH_SERVER_URL_CONFIG_KEY);
}

@BuildStep(onlyIf = IsDevelopment.class)
KeycloakAdminPageBuildItem addCardWithLinkToKeycloakAdmin() {
return new KeycloakAdminPageBuildItem(new CardPageBuildItem());
}

private static String getInitialToken() {
return ConfigProvider.getConfig().getOptionalValue("quarkus.oidc-client-registration.initial-token", String.class)
.orElse(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package io.quarkus.oidc.client.registration;

import static org.junit.jupiter.api.Assertions.assertEquals;

import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;

import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.runtime.StartupEvent;
import io.quarkus.test.QuarkusUnitTest;

public class OidcClientRegistrationKeycloakDevServiceTest {

@RegisterExtension
static final QuarkusUnitTest test = new QuarkusUnitTest()
.withApplicationRoot((jar) -> jar
.addAsResource(
new StringAsset(
"""
quarkus.oidc-client-registration.metadata.client-name=Default Test Client
quarkus.oidc-client-registration.metadata.redirect-uri=http://localhost:8081/default/redirect
quarkus.oidc-client-registration.named.metadata.client-name=Named Test Client
quarkus.oidc-client-registration.named.metadata.redirect-uri=http://localhost:8081/named/redirect
quarkus.oidc-client-registration.named.auth-server-url=${quarkus.oidc-client-registration.auth-server-url}
"""),
"application.properties"));

@Inject
TestClientRegistrations testClientRegistrations;

@Test
public void testDefaultRegisteredClient() {
assertEquals("Default Test Client", testClientRegistrations.defaultClientMetadata.getClientName());
assertEquals("http://localhost:8081/default/redirect",
testClientRegistrations.defaultClientMetadata.getRedirectUris().get(0));
}

@Test
public void testNamedRegisteredClient() {
assertEquals("Named Test Client", testClientRegistrations.namedClientMetadata.getClientName());
assertEquals("http://localhost:8081/named/redirect",
testClientRegistrations.namedClientMetadata.getRedirectUris().get(0));
}

@Singleton
public static final class TestClientRegistrations {

private volatile ClientMetadata defaultClientMetadata;
private volatile ClientMetadata namedClientMetadata;

void prepareDefaultClientMetadata(@Observes StartupEvent event, OidcClientRegistrations clientRegistrations) {
var clientRegistration = clientRegistrations.getClientRegistration();
var registeredClient = clientRegistration.registeredClient().await().indefinitely();
defaultClientMetadata = registeredClient.metadata();

clientRegistration = clientRegistrations.getClientRegistration("named");
registeredClient = clientRegistration.registeredClient().await().indefinitely();
namedClientMetadata = registeredClient.metadata();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,11 @@ public static Uni<OidcClientRegistration> createOidcClientRegistrationUni(OidcCl
if (isEmptyMetadata(oidcConfig.metadata)) {
return Uni.createFrom().nullItem();
}
var clientName = DEFAULT_ID.equals(oidcConfig.id.orElse(DEFAULT_ID)) ? "" : "." + oidcConfig.id.get();
throw new ConfigurationException(
"Either 'quarkus.oidc-client-registration.auth-server-url' or absolute 'quarkus.oidc-client-registration.registration-path' URL must be set");
"Either 'quarkus.oidc-client-registration" + clientName
+ ".auth-server-url' or absolute 'quarkus.oidc-client-registration" + clientName
+ ".registration-path' URL must be set");
}
OidcCommonUtils.verifyEndpointUrl(getEndpointUrl(oidcConfig));
} catch (Throwable t) {
Expand Down Expand Up @@ -194,7 +197,7 @@ public Uni<OidcClientRegistration> apply(OidcConfigurationMetadata metadata, Thr
public OidcClientRegistration apply(RegisteredClient r, Throwable t2) {
RegisteredClient registeredClient;
if (t2 != null) {
LOG.errorf("%s client registartion failed: %s, it can be retried later",
LOG.errorf("%s client registration failed: %s, it can be retried later",
oidcConfig.id.orElse(DEFAULT_ID), t2.getMessage());
registeredClient = null;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
import io.quarkus.arc.processor.DotNames;
import io.quarkus.deployment.ApplicationArchive;
import io.quarkus.deployment.Feature;
import io.quarkus.deployment.IsDevelopment;
import io.quarkus.deployment.IsNormal;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.BuildSteps;
Expand All @@ -37,11 +35,6 @@
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem;
import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem;
import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig;
import io.quarkus.devservices.keycloak.KeycloakDevServicesConfigBuildItem;
import io.quarkus.devservices.keycloak.KeycloakDevServicesRequiredBuildItem;
import io.quarkus.devui.spi.page.CardPageBuildItem;
import io.quarkus.devui.spi.page.Page;
import io.quarkus.gizmo.ClassCreator;
import io.quarkus.gizmo.ClassOutput;
import io.quarkus.gizmo.MethodCreator;
Expand Down Expand Up @@ -191,27 +184,6 @@ private AccessTokenInstanceBuildItem build() {
return index.getIndex().getAnnotations(ACCESS_TOKEN).stream().map(ItemBuilder::new).map(ItemBuilder::build).toList();
}

@BuildStep(onlyIfNot = IsNormal.class, onlyIf = GlobalDevServicesConfig.Enabled.class)
KeycloakDevServicesRequiredBuildItem requireKeycloakDevService() {
// this needs to be done as the shared Keycloak Dev Service doesn't know if the OIDC Client is enabled
return KeycloakDevServicesRequiredBuildItem.requireDevServiceForOidcClient();
}

@BuildStep(onlyIf = IsDevelopment.class)
void produceDevUiCardWithKeycloakUrl(Optional<KeycloakDevServicesConfigBuildItem> configProps,
BuildProducer<CardPageBuildItem> cardPageProducer) {
final String keycloakAdminUrl = configProps.map(item -> item.getConfig().get("keycloak.url")).orElse(null);
if (keycloakAdminUrl != null) {
// Add Admin page
final CardPageBuildItem cardPage = new CardPageBuildItem();
cardPage.addPage(Page.externalPageBuilder("Keycloak Admin")
.icon("font-awesome-solid:key")
.doNotEmbed(true)
.url(keycloakAdminUrl));
cardPageProducer.produce(cardPage);
}
}

/**
* Creates a Tokens producer class like follows:
*
Expand Down
Loading

0 comments on commit 6a34154

Please sign in to comment.