Skip to content

Commit

Permalink
Merge pull request #19969 from sberyozkin/oidc_dev_console
Browse files Browse the repository at this point in the history
Support for provider-neutral OIDC Dev UI
  • Loading branch information
sberyozkin authored Sep 8, 2021
2 parents 4275336 + a93629f commit 0809f59
Show file tree
Hide file tree
Showing 13 changed files with 317 additions and 92 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 41 additions & 6 deletions docs/src/main/asciidoc/security-openid-connect-dev-services.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ This guide is maintained in the main Quarkus repository
and pull requests should be submitted there:
https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc
////
= Dev Services for OpenId Connect (OIDC)
= Dev Services and UI for OpenId Connect (OIDC)

include::./attributes.adoc[]

This guide covers the Dev Services for OpenId Connect (OIDC) Keycloak provider and explains how to support other OpenId Connect providers.
This guide covers the Dev Services and UI for OpenId Connect (OIDC) Keycloak provider and explains how to support Dev Services and UI for other OpenId Connect providers.
It also describes Dev UI for all OpenId Connect providers which have already been started before Quarkus is launched in a dev mode.

== Introduction

Quarkus introduces an experimental `Dev Services For Keycloak` feature which is enabled by default when the `quarkus-oidc` extension is started in dev mode with `mvn quarkus:dev` and when the integration tests are running in test mode, but only when no `quarkus.oidc.auth-server-url` property is configured.
It starts a Keycloak container for both the dev and/or test modes and initializes them by registering the existing Keycloak realm or creating a new realm with the client and users for you to start developing your Quarkus application secured by Keycloak immediately. It will restart the container when the `application.properties` or the realm file changes have been detected.

Additionally, link:dev-ui[Dev UI] available at http://localhost:8080/q/dev[/q/dev] supports this feature with a Keycloak specific page which helps to acquire the tokens from Keycloak and test your Quarkus application.
Additionally, link:dev-ui[Dev UI] available at http://localhost:8080/q/dev[/q/dev] complements this feature with a Dev UI page which helps to acquire the tokens from Keycloak and test your Quarkus application.

If `quarkus.oidc.auth-server-url` is already set then a generic OpenId Connect Dev Console which can be used with all OpenId Connect providers will be activated, please see <<dev-ui-all-oidc-providers,Dev UI for all OpenId Connect Providers>> for more information.

== Dev Services for Keycloak

Expand Down Expand Up @@ -58,6 +61,7 @@ Click on the `Provider: Keycloak` link and you will see a Keycloak page which wi

By default the Keycloak page can be used to support the development of a link:security-openid-connect[Quarkus OIDC service application].

[[keycloak-authorization-code-grant]]
==== Authorization Code Grant

If you set `quarkus.keycloak.devservices.grant.type=code` in `applicatin.properties` (this is a default value) then an `authorization_code` grant will be used to acquire both access and ID tokens. Using this grant is recommended to emulate a typical flow where a `Single Page Application` acquires the tokens and uses them to access Quarkus services.
Expand Down Expand Up @@ -172,13 +176,44 @@ Note that even if you initialize Keycloak from a realm file, it is still needed

If you prefer not to have a `Dev Services for Keycloak` container started or do not work with Keycloak then you can also disable this feature with `quarkus.keycloak.devservices.enabled=false` - it will only be necessary if you expect to start `quarkus:dev` without `quarkus.oidc.auth-server-url`.

The main Dev UI page will include an empty `OpenId Connect Card` when `Dev Services for Keycloak` is disabled:
The main Dev UI page will include an empty `OpenId Connect Card` when `Dev Services for Keycloak` is disabled and the `quarkus.oidc.auth-server-url` property
has not been initialized:

image::dev-ui-oidc-card.png[alt=Dev UI OpenId Connect Card,role="center"]

== Dev Services Support for other OpenId Connect Providers
If `quarkus.oidc.auth-server-url` is already set then a generic OpenId Connect Dev Console which can be used with all OpenId Connect providers may be activated, please see <<dev-ui-all-oidc-providers,Dev UI for all OpenId Connect Providers>> for more information.

[[dev-ui-all-oidc-providers]]
== Dev UI for all OpenId Connect Providers

If `quarkus.oidc.auth-server-url` points to an already started OpenId Connect provider (which can be Keycloak or other provider), `quarkus.oidc.auth-server-url` is set to `service` (which is a default value) and at least `quarkus.oidc.client-id` is set then `Dev UI for all OpenId Connect Providers` will be activated.

Setting `quarkus.oidc.credentials.secret` will mostly likely be required for Keycloak and other providers for the authorization code flow initiated from Dev UI to complete, unless the client identified with `quarkus.oidc.client-id` is configured as a public client in your OpenId Connect provider's administration console.

Run `mvn`quarkus:dev` and you will see the following message:
[source,shell]
----
$ mvn quarkus:dev
...
2021-09-07 15:53:42,697 INFO [io.qua.oid.dep.dev.OidcDevConsoleProcessor] (build-41) OIDC Dev Console: discovering the provider metadata at http://localhost:8180/auth/realms/quarkus/.well-known/openid-configuration
...
----

If the provider metadata discovery has been successful then, after you open the main link:http://localhost:8080/q/dev[Dev UI page], you will see the `OpenId Connect Card` page linking to `Dev Console`:

image::dev-ui-oidc-devconsole-card.png[alt=Generic Dev UI OpenId Connect Card,role="center"]

Follow the link and you'll be able log in to your provider, get the tokens and test the application. The experience will be the same as described in the <<keycloak-authorization-code-grant,Authorization Code Grant for Keycloak>> section, where `Dev Services for Keycloak` container has been started, especially if you work with Keycloak (please also pay attention to a `redirect_uri` note in that section).

If you work with other providers then a Dev UI experience described in the <<keycloak-authorization-code-grant,Authorization Code Grant for Keycloak>> section might differ slightly. For example, an access token may not be in a JWT format so it won't be possibe to show its internal content, though all providers should return an ID Token as JWT.

Some providers such as `Auth0` do not support a standard RP initiated logout so a logout option will also be hidden.

At the moment `Dev UI for all OpenId Connect Providers` only supports an authorization code grant. More grants may be supported in the future, similarly to how it is done with `Dev Services for Keycloak`.

== Dev Services and UI Support for other OpenId Connect Providers

Your custom extension would need to extend `quarkus-oidc` only and add the dependencies required to support your provider to the extension's `deployment` module only.
Your custom extension would need to extend `quarkus-oidc` and add the dependencies required to support your provider to the extension's `deployment` module only.

The build step dealing with the `Dev Services` should additionally register two runtime properties into the "io.quarkus.quarkus-oidc" namespace: `oidcProviderName` (for example, `Google`) and `oidcProviderUrlBase` (for example: `mycompany.devservices-google`) for the `OpenId Connect Card` to link to the Dev UI page representing your provider, for example:

Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
package io.quarkus.oidc.deployment.devservices.keycloak;
package io.quarkus.oidc.deployment.devservices;

import java.time.Duration;

import org.jboss.logging.Logger;

import io.quarkus.devconsole.runtime.spi.DevConsolePostHandler;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.vertx.core.MultiMap;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpHeaders;
import io.vertx.ext.web.RoutingContext;
import io.vertx.mutiny.core.buffer.Buffer;
import io.vertx.mutiny.ext.web.client.HttpRequest;
import io.vertx.mutiny.ext.web.client.HttpResponse;
import io.vertx.mutiny.ext.web.client.WebClient;

public class KeycloakAuthorizationCodePostHandler extends DevConsolePostHandler {
private static final Logger LOG = Logger.getLogger(KeycloakAuthorizationCodePostHandler.class);
public class OidcAuthorizationCodePostHandler extends DevConsolePostHandler {
private static final Logger LOG = Logger.getLogger(OidcAuthorizationCodePostHandler.class);

Vertx vertxInstance;
Duration timeout;

public OidcAuthorizationCodePostHandler(Vertx vertxInstance, Duration timeout) {
this.vertxInstance = vertxInstance;
this.timeout = timeout;
}

@Override
protected void handlePostAsync(RoutingContext event, MultiMap form) throws Exception {
WebClient client = KeycloakDevServicesUtils.createWebClient();
String keycloakUrl = form.get("keycloakUrl") + "/realms/" + form.get("realm") + "/protocol/openid-connect/token";
WebClient client = OidcDevServicesUtils.createWebClient(vertxInstance);
String tokenUrl = form.get("tokenUrl");

try {
LOG.infof("Using authorization_code grant to get a token from '%s' in realm '%s' with client id '%s'",
keycloakUrl, form.get("realm"), form.get("client"));
LOG.infof("Using authorization_code grant to get a token from '%s' with client id '%s'",
tokenUrl, form.get("client"));

HttpRequest<Buffer> request = client.postAbs(keycloakUrl);
HttpRequest<Buffer> request = client.postAbs(tokenUrl);
request.putHeader(HttpHeaders.CONTENT_TYPE.toString(), HttpHeaders.APPLICATION_X_WWW_FORM_URLENCODED.toString());

io.vertx.mutiny.core.MultiMap props = new io.vertx.mutiny.core.MultiMap(MultiMap.caseInsensitiveMultiMap());
props.add("client_id", form.get("client"));
if (form.get("clientSecret") != null) {
if (form.get("clientSecret") != null && !form.get("clientSecret").isBlank()) {
props.add("client_secret", form.get("clientSecret"));
}
props.add("grant_type", "authorization_code");
Expand All @@ -38,12 +49,12 @@ protected void handlePostAsync(RoutingContext event, MultiMap form) throws Excep

String tokens = request.sendBuffer(OidcCommonUtils.encodeForm(props)).onItem()
.transform(resp -> getBodyAsString(resp))
.await().atMost(KeycloakDevServicesProcessor.capturedDevServicesConfiguration.webClienTimeout);
.await().atMost(timeout);

event.put("tokens", tokens);

} catch (Throwable t) {
LOG.errorf("Token can not be acquired from Keycloak: %s", t.toString());
LOG.errorf("Token can not be acquired from OpenId Connect provider: %s", t.toString());
} finally {
client.close();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package io.quarkus.oidc.deployment.devservices;

import java.time.Duration;

import org.eclipse.microprofile.config.ConfigProvider;
import org.jboss.logging.Logger;

import io.quarkus.bootstrap.classloading.QuarkusClassLoader;
import io.quarkus.deployment.IsDevelopment;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.Consume;
import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem;
import io.quarkus.devconsole.spi.DevConsoleRouteBuildItem;
import io.quarkus.devconsole.spi.DevConsoleTemplateInfoBuildItem;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.runtime.configuration.ConfigUtils;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.json.JsonObject;
import io.vertx.mutiny.core.buffer.Buffer;
import io.vertx.mutiny.ext.web.client.HttpResponse;
import io.vertx.mutiny.ext.web.client.WebClient;

public class OidcDevConsoleProcessor {
static volatile Vertx vertxInstance;
private static final Logger LOG = Logger.getLogger(OidcDevConsoleProcessor.class);

private static final String CONFIG_PREFIX = "quarkus.oidc.";
private static final String TENANT_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "tenant-enabled";
private static final String AUTH_SERVER_URL_CONFIG_KEY = CONFIG_PREFIX + "auth-server-url";
private static final String APP_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type";
private static final String SERVICE_APP_TYPE = "service";
private static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id";
private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret";

@BuildStep(onlyIf = IsDevelopment.class)
@Consume(RuntimeConfigSetupCompleteBuildItem.class)
void prepareOidcDevConsole(BuildProducer<DevConsoleTemplateInfoBuildItem> console,
BuildProducer<DevConsoleRouteBuildItem> devConsoleRoute) {
if (isOidcTenantEnabled() && isAuthServerUrlSet() && isClientIdSet() && isServiceAuthType()) {

if (vertxInstance == null) {
vertxInstance = Vertx.vertx();

Runnable closeTask = new Runnable() {
@Override
public void run() {
if (vertxInstance != null) {
try {
vertxInstance.close();
} catch (Throwable t) {
LOG.error("Failed to close Vertx instance", t);
}
}
vertxInstance = null;
}
};
QuarkusClassLoader cl = (QuarkusClassLoader) Thread.currentThread().getContextClassLoader();
((QuarkusClassLoader) cl.parent()).addCloseTask(closeTask);
Thread closeHookThread = new Thread(closeTask, "OIDC DevConsole Vertx close thread");
Runtime.getRuntime().addShutdownHook(closeHookThread);
((QuarkusClassLoader) cl.parent()).addCloseTask(new Runnable() {
@Override
public void run() {
Runtime.getRuntime().removeShutdownHook(closeHookThread);
}
});
}

String authServerUrl = getConfigProperty(AUTH_SERVER_URL_CONFIG_KEY);
JsonObject metadata = discoverMetadata(authServerUrl);
if (metadata == null) {
return;
}
if (authServerUrl.contains("/realms/")) {
console.produce(new DevConsoleTemplateInfoBuildItem("keycloakAdminUrl",
authServerUrl.substring(0, authServerUrl.indexOf("/realms/"))));
}
console.produce(new DevConsoleTemplateInfoBuildItem("oidcApplicationType", SERVICE_APP_TYPE));
console.produce(new DevConsoleTemplateInfoBuildItem("clientId", getConfigProperty(CLIENT_ID_CONFIG_KEY)));
console.produce(new DevConsoleTemplateInfoBuildItem("clientSecret", getClientSecret()));

console.produce(new DevConsoleTemplateInfoBuildItem("tokenUrl", metadata.getString("token_endpoint")));
console.produce(
new DevConsoleTemplateInfoBuildItem("authorizationUrl", metadata.getString("authorization_endpoint")));
if (metadata.containsKey("end_session_endpoint")) {
console.produce(new DevConsoleTemplateInfoBuildItem("logoutUrl", metadata.getString("end_session_endpoint")));
}
console.produce(new DevConsoleTemplateInfoBuildItem("oidcGrantType", "code"));

devConsoleRoute.produce(new DevConsoleRouteBuildItem("testServiceWithToken", "POST",
new OidcTestServiceHandler(vertxInstance, Duration.ofSeconds(3))));
devConsoleRoute.produce(new DevConsoleRouteBuildItem("exchangeCodeForTokens", "POST",
new OidcAuthorizationCodePostHandler(vertxInstance, Duration.ofSeconds(3))));
}
}

private JsonObject discoverMetadata(String authServerUrl) {
WebClient client = OidcDevServicesUtils.createWebClient(vertxInstance);
try {
String metadataUrl = authServerUrl + OidcConstants.WELL_KNOWN_CONFIGURATION;
LOG.infof("OIDC Dev Console: discovering the provider metadata at %s", metadataUrl);

HttpResponse<Buffer> resp = client.getAbs(metadataUrl)
.putHeader(HttpHeaders.ACCEPT.toString(), "application/json").send().await().indefinitely();
if (resp.statusCode() == 200) {
return resp.bodyAsJsonObject();
} else {
LOG.errorf("OIDC metadata discovery failed: %s", resp.bodyAsString());
return null;
}
} catch (Throwable t) {
LOG.errorf("OIDC metadata discovery failed: %s", t.toString());
return null;
} finally {
client.close();
}
}

private String getConfigProperty(String name) {
return ConfigProvider.getConfig().getValue(name, String.class);
}

private static boolean isOidcTenantEnabled() {
return ConfigProvider.getConfig().getOptionalValue(TENANT_ENABLED_CONFIG_KEY, Boolean.class).orElse(true);
}

private static boolean isClientIdSet() {
return ConfigUtils.isPropertyPresent(CLIENT_ID_CONFIG_KEY);
}

private static String getClientSecret() {
return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class).orElse("");
}

private static boolean isAuthServerUrlSet() {
return ConfigUtils.isPropertyPresent(AUTH_SERVER_URL_CONFIG_KEY);
}

private boolean isServiceAuthType() {
return SERVICE_APP_TYPE.equals(
ConfigProvider.getConfig().getOptionalValue(APP_TYPE_CONFIG_KEY, String.class).orElse(SERVICE_APP_TYPE));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import io.quarkus.builder.item.SimpleBuildItem;

/**
* Marker build item which indicates that Dev Services for OIDC are provided by another extension.
* Dev Services for Keycloak will be disabled if this item is detected.
*/
public class OidcDevServicesBuildItem extends SimpleBuildItem {

}
Loading

0 comments on commit 0809f59

Please sign in to comment.