From ed9157e1322425c3ee61f9d6a0161cdfd9a31164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 11 Aug 2023 16:20:47 +0200 Subject: [PATCH 1/5] Added lightweight keycloak dev services option --- .../keycloak/KeycloakDevServicesConfig.java | 6 + .../devservices/OidcDevUIProcessor.java | 10 +- ...LightweightDevServicesConfigBuildItem.java | 18 + .../LightweightDevServicesProcessor.java | 628 ++++++++++++++++++ 4 files changed, 659 insertions(+), 3 deletions(-) create mode 100644 extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java create mode 100644 extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java diff --git a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java index 9a0087eea9e26..20857559b3c47 100644 --- a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java +++ b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java @@ -28,6 +28,12 @@ public interface KeycloakDevServicesConfig { @WithDefault("true") boolean enabled(); + /** + * Use lightweight dev services instead of Keycloak + */ + @ConfigItem(defaultValue = "false") + public boolean lightweight; + /** * The container image name for Dev Services providers. * diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java index 777a11452a1d8..def279cb26ad3 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java @@ -24,6 +24,7 @@ import io.quarkus.oidc.OidcTenantConfig.Provider; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.deployment.OidcBuildTimeConfig; +import io.quarkus.oidc.deployment.devservices.keycloak.LightweightDevServicesConfigBuildItem; import io.quarkus.oidc.runtime.devui.OidcDevJsonRpcService; import io.quarkus.oidc.runtime.devui.OidcDevServicesUtils; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; @@ -67,12 +68,15 @@ void prepareOidcDevConsole(CuratedApplicationShutdownBuildItem closeBuildItem, NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem, BuildProducer cardPageProducer, ConfigurationBuildItem configurationBuildItem, - OidcDevUiRecorder recorder) { - if (!isOidcTenantEnabled() || !isClientIdSet()) { + OidcDevUiRecorder recorder, + Optional lightweightDevServicesConfigBuildItem) { + if (!isOidcTenantEnabled() || (!isClientIdSet() && lightweightDevServicesConfigBuildItem.isEmpty())) { return; } final OidcTenantConfig providerConfig = getProviderConfig(); - final String authServerUrl = getAuthServerUrl(providerConfig); + final String authServerUrl = lightweightDevServicesConfigBuildItem.isPresent() + ? lightweightDevServicesConfigBuildItem.get().getConfig().get(AUTH_SERVER_URL_CONFIG_KEY) + : getAuthServerUrl(providerConfig); if (authServerUrl != null) { if (vertxInstance == null) { vertxInstance = Vertx.vertx(); diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java new file mode 100644 index 0000000000000..30d9fac042b9b --- /dev/null +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java @@ -0,0 +1,18 @@ +package io.quarkus.oidc.deployment.devservices.keycloak; + +import java.util.Map; + +import io.quarkus.builder.item.SimpleBuildItem; + +public final class LightweightDevServicesConfigBuildItem extends SimpleBuildItem { + + private final Map config; + + public LightweightDevServicesConfigBuildItem(Map config) { + this.config = config; + } + + public Map getConfig() { + return config; + } +} diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java new file mode 100644 index 0000000000000..c122e00272501 --- /dev/null +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java @@ -0,0 +1,628 @@ +package io.quarkus.oidc.deployment.devservices.keycloak; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.jwt.Claims; +import org.jboss.logging.Logger; + +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem.RunningDevService; +import io.quarkus.deployment.builditem.DevServicesSharedNetworkBuildItem; +import io.quarkus.deployment.builditem.LaunchModeBuildItem; +import io.quarkus.deployment.console.ConsoleInstalledBuildItem; +import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; +import io.quarkus.deployment.logging.LoggingSetupBuildItem; +import io.quarkus.oidc.deployment.OidcBuildStep.IsEnabled; +import io.quarkus.oidc.deployment.OidcBuildTimeConfig; +import io.quarkus.oidc.deployment.devservices.OidcDevServicesBuildItem; +import io.quarkus.runtime.configuration.ConfigUtils; +import io.smallrye.jwt.build.Jwt; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.core.http.HttpServer; +import io.vertx.mutiny.ext.web.Router; +import io.vertx.mutiny.ext.web.RoutingContext; +import io.vertx.mutiny.ext.web.handler.BodyHandler; + +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = { IsEnabled.class, GlobalDevServicesConfig.Enabled.class }) +public class LightweightDevServicesProcessor { + + private static final Logger LOG = Logger.getLogger(LightweightDevServicesProcessor.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 PROVIDER_CONFIG_KEY = CONFIG_PREFIX + "provider"; + private static final String APPLICATION_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; + private static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; + private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret"; + + private static volatile RunningDevService devService; + static volatile DevServicesConfig capturedDevServicesConfiguration; + private static volatile boolean first = true; + + OidcBuildTimeConfig oidcConfig; + + private static volatile KeyPair kp; + private static volatile String baseURI; + private static volatile String clientId; + + @BuildStep + public DevServicesResultBuildItem startLightweightServer( + List devServicesSharedNetworkBuildItem, + Optional oidcProviderBuildItem, + KeycloakBuildTimeConfig config, + CuratedApplicationShutdownBuildItem closeBuildItem, + LaunchModeBuildItem launchMode, + Optional consoleInstalledBuildItem, + BuildProducer lightweightBuildItemBuildProducer, + LoggingSetupBuildItem loggingSetupBuildItem, + GlobalDevServicesConfig devServicesConfig) { + + if (oidcProviderBuildItem.isPresent()) { + // Dev Services for the alternative OIDC provider are enabled + return null; + } + + if (!config.devservices.lightweight) { + return null; + } + LOG.info("Starting Lightweight OIDC dev services"); + + DevServicesConfig currentDevServicesConfiguration = config.devservices; + // Figure out if we need to shut down and restart any existing Keycloak container + // if not and the Keycloak container has already started we just return + if (devService != null) { + try { + devService.close(); + } catch (Throwable e) { + LOG.error("Failed to stop lightweight container", e); + } + devService = null; + capturedDevServicesConfiguration = null; + } + capturedDevServicesConfiguration = currentDevServicesConfiguration; + try { + List errors = new ArrayList<>(); + + RunningDevService newDevService = startLightweightServer(lightweightBuildItemBuildProducer, + !devServicesSharedNetworkBuildItem.isEmpty(), + devServicesConfig.timeout, + errors); + if (newDevService == null) { + return null; + } + + devService = newDevService; + + if (first) { + first = false; + Runnable closeTask = new Runnable() { + @Override + public void run() { + if (devService != null) { + try { + devService.close(); + } catch (Throwable t) { + LOG.error("Failed to stop Keycloak container", t); + } + } + first = true; + devService = null; + capturedDevServicesConfiguration = null; + } + }; + closeBuildItem.addCloseTask(closeTask, true); + } + } catch (Throwable t) { + throw new RuntimeException(t); + } + LOG.infof("Dev Services for lightweight OIDC started on %s", baseURI); + + return devService.toBuildItem(); + } + + private RunningDevService startLightweightServer( + BuildProducer lightweightBuildItemBuildProducer, + boolean useSharedNetwork, Optional timeout, + List errors) { + if (!capturedDevServicesConfiguration.enabled) { + // explicitly disabled + LOG.debug("Not starting Dev Services for Keycloak as it has been disabled in the config"); + return null; + } + if (!isOidcTenantEnabled()) { + LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.tenant.enabled' is false"); + return null; + } + if (ConfigUtils.isPropertyPresent(AUTH_SERVER_URL_CONFIG_KEY)) { + LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.auth-server-url' has been provided"); + return null; + } + if (ConfigUtils.isPropertyPresent(PROVIDER_CONFIG_KEY)) { + LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.provider' has been provided"); + return null; + } + + Vertx vertx = Vertx.vertx(); + HttpServerOptions options = new HttpServerOptions(); + options.setPort(0); + HttpServer httpServer = vertx.createHttpServer(options); + + Router router = Router.router(vertx); + httpServer.requestHandler(router); + registerRoutes(router); + + httpServer.listenAndAwait(); + int port = httpServer.actualPort(); + + Map configProperties = new HashMap<>(); + baseURI = "http://localhost:" + port; + clientId = getOidcClientId(); + String oidcClientSecret = getOidcClientSecret(); + String oidcApplicationType = getOidcApplicationType(); + configProperties.put(AUTH_SERVER_URL_CONFIG_KEY, baseURI); + configProperties.put(APPLICATION_TYPE_CONFIG_KEY, oidcApplicationType); + configProperties.put(CLIENT_ID_CONFIG_KEY, clientId); + configProperties.put(CLIENT_SECRET_CONFIG_KEY, oidcClientSecret); + + lightweightBuildItemBuildProducer + .produce(new LightweightDevServicesConfigBuildItem(configProperties)); + + return new RunningDevService("oidc-lightweight", null, () -> { + LOG.info("Closing Vertx DEV service for oidc lightweight"); + vertx.closeAndAwait(); + }, configProperties); + } + + private void registerRoutes(Router router) { + BodyHandler bodyHandler = BodyHandler.create(); + router.get("/").handler(this::mainRoute); + router.get("/.well-known/openid-configuration").handler(this::configuration); + router.get("/authorize").handler(this::authorize); + router.post("/login").handler(bodyHandler).handler(this::login); + router.post("/token").handler(bodyHandler).handler(this::accessTokenJson); + router.get("/keys").handler(this::getKeys); + router.get("/logout").handler(this::logout); + + KeyPairGenerator kpg; + try { + kpg = KeyPairGenerator.getInstance("RSA"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + kpg.initialize(2048); + kp = kpg.generateKeyPair(); + } + + private List getUsers() { + if (capturedDevServicesConfiguration.roles.isEmpty()) { + return Arrays.asList("alice", "bob"); + } else { + List ret = new ArrayList<>(capturedDevServicesConfiguration.roles.keySet()); + Collections.sort(ret); + return ret; + } + } + + private List getUserRoles(String user) { + List roles = capturedDevServicesConfiguration.roles.get(user); + return roles == null ? ("alice".equals(user) ? List.of("admin", "user") : List.of("user")) + : roles; + } + + private static boolean isOidcTenantEnabled() { + return ConfigProvider.getConfig().getOptionalValue(TENANT_ENABLED_CONFIG_KEY, Boolean.class).orElse(true); + } + + private static String getOidcApplicationType() { + return ConfigProvider.getConfig().getOptionalValue(APPLICATION_TYPE_CONFIG_KEY, String.class).orElse("service"); + } + + private static String getOidcClientId() { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_ID_CONFIG_KEY, String.class) + .orElse("quarkus-app"); + } + + private static String getOidcClientSecret() { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class) + .orElse("the secret must be 32 characters at least to avoid a warning"); + } + + private void mainRoute(RoutingContext rc) { + rc.response().endAndForget("Lightweight OIDC server up and running"); + } + + private void configuration(RoutingContext rc) { + String data = "{\n" + + " \"token_endpoint\":\"" + baseURI + "/token\",\n" + + " \"token_endpoint_auth_methods_supported\":[\n" + + " \"client_secret_post\",\n" + + " \"private_key_jwt\",\n" + + " \"client_secret_basic\"\n" + + " ],\n" + + " \"jwks_uri\":\"" + baseURI + "/keys\",\n" + + " \"response_modes_supported\":[\n" + + " \"query\",\n" + + " \"fragment\",\n" + + " \"form_post\"\n" + + " ],\n" + + " \"subject_types_supported\":[\n" + + " \"pairwise\"\n" + + " ],\n" + + " \"id_token_signing_alg_values_supported\":[\n" + + " \"RS256\"\n" + + " ],\n" + + " \"response_types_supported\":[\n" + + " \"code\",\n" + + " \"id_token\",\n" + + " \"code id_token\",\n" + + " \"id_token token\"\n" + + " ],\n" + + " \"scopes_supported\":[\n" + + " \"openid\",\n" + + " \"profile\",\n" + + " \"email\",\n" + + " \"offline_access\"\n" + + " ],\n" + + " \"issuer\":\"" + baseURI + "/lightweight\",\n" + + " \"request_uri_parameter_supported\":false,\n" + + " \"userinfo_endpoint\":\"" + baseURI + "/userinfo\",\n" + + " \"authorization_endpoint\":\"" + baseURI + "/authorize\",\n" + + " \"device_authorization_endpoint\":\"" + baseURI + "/devicecode\",\n" + + " \"http_logout_supported\":true,\n" + + " \"frontchannel_logout_supported\":true,\n" + + " \"end_session_endpoint\":\"" + baseURI + "/logout\",\n" + + " \"claims_supported\":[\n" + + " \"sub\",\n" + + " \"iss\",\n" + + " \"aud\",\n" + + " \"exp\",\n" + + " \"iat\",\n" + + " \"auth_time\",\n" + + " \"acr\",\n" + + " \"nonce\",\n" + + " \"preferred_username\",\n" + + " \"name\",\n" + + " \"tid\",\n" + + " \"ver\",\n" + + " \"at_hash\",\n" + + " \"c_hash\",\n" + + " \"email\"\n" + + " ]\n" + + "}"; + rc.response().putHeader("Content-Type", "application/json"); + rc.endAndForget(data); + } + + /* + * First request: + * GET + * https://localhost:X/authorize?response_type=code&client_id=SECRET&scope=openid+openid+ + * email+profile&redirect_uri=http://localhost:8080/Login/oidcLoginSuccess&state=STATE + * + * returns a 302 to + * GET http://localhost:8080/Login/oidcLoginSuccess?code=CODE&state=STATE + */ + private void authorize(RoutingContext rc) { + String response_type = rc.request().params().get("response_type"); + String clientId = rc.request().params().get("client_id"); + String scope = rc.request().params().get("scope"); + String state = rc.request().params().get("state"); + String redirect_uri = rc.request().params().get("redirect_uri"); + UUID code = UUID.randomUUID(); + URI redirect; + try { + redirect = new URI(redirect_uri + "?state=" + state); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + StringBuilder predefinedUsers = new StringBuilder(); + for (String predefinedUser : getUsers()) { + predefinedUsers.append(" \n"); + } + rc.response() + .endAndForget("" + + " " + + " Login" + + " \n" + + " \n" + + " \n" + + "
\n" + + "
\n" + + "
Login
\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + predefinedUsers + + "
\n" + + "
\n" + + " Custom user\n" + + "
\n" + + " " + + "
" + + "
" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + ""); + } + + private void login(RoutingContext rc) { + String redirect_uri = rc.request().params().get("redirect_uri"); + String predefined = rc.request().params().get("predefined"); + String name = rc.request().params().get("name"); + String roles = rc.request().params().get("roles"); + if (predefined != null) { + name = predefined; + roles = String.join(",", getUserRoles(name)); + } + if (name == null || name.isBlank()) { + name = "user"; + } + // store user|roles in the code param as Base64 + String code = Base64.getUrlEncoder().encodeToString((name + "|" + roles).getBytes(StandardCharsets.UTF_8)); + rc.response() + .putHeader("Location", redirect_uri + "&code=" + code) + .setStatusCode(302) + .endAndForget(); + } + + /* + * OIDC calls POST /token + * grant_type=authorization_code + * &code=CODE + * &redirect_uri=URI + * + * returns: + * + * { + * "token_type":"Bearer", + * "scope":"openid email profile", + * "expires_in":3600, + * "ext_expires_in":3600, + * "access_token":TOKEN, + * "id_token":JWT + * } + * + * ID token: + * { + * "ver": "2.0", + * "iss": "http://localhost/lightweight", + * "sub": "USERID", + * "aud": "CLIENTID", + * "exp": 1641906214, + * "iat": 1641819514, + * "nbf": 1641819514, + * "name": "Foo Bar", + * "preferred_username": "user@example.com", + * "oid": "OPAQUE", + * "email": "user@example.com", + * "tid": "TENANTID", + * "aio": "AZURE_OPAQUE" + * } + */ + private void accessTokenJson(RoutingContext rc) { + String authorization_code = rc.request().formAttributes().get("authorization_code"); + String code = rc.request().formAttributes().get("code"); + String redirect_uri = rc.request().formAttributes().get("redirect_uri"); + String decodedCode = new String(Base64.getUrlDecoder().decode(code), StandardCharsets.UTF_8); + int separator = decodedCode.indexOf('|'); + String user = decodedCode.substring(0, separator); + String rolesAsString = decodedCode.substring(separator + 1); + Set roles = new HashSet<>(Arrays.asList(rolesAsString.split("[,\\s]+"))); + + String accessToken = Jwt.claims() + .expiresIn(Duration.ofDays(1)) + .issuedAt(Instant.now()) + .issuer(baseURI + "/lightweight") + .subject(user) + .upn(user) + // not sure if the next three are even used + .claim("name", "Foo Bar") + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId("KEYID") + .sign(kp.getPrivate()); + String idToken = Jwt.claims() + .expiresIn(Duration.ofDays(1)) + .issuedAt(Instant.now()) + .issuer(baseURI + "/lightweight") + .audience(clientId) + .subject(user) + .upn(user) + .claim("name", "Foo Bar") + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId("KEYID") + .sign(kp.getPrivate()); + + String data = "{\n" + + " \"token_type\":\"Bearer\",\n" + + " \"scope\":\"openid email profile\",\n" + + " \"expires_in\":3600,\n" + + " \"ext_expires_in\":3600,\n" + + " \"access_token\":\"" + accessToken + "\",\n" + + " \"id_token\":\"" + idToken + "\"\n" + + " } "; + rc.response() + .putHeader("Content-Type", "application/json") + .endAndForget(data); + } + + /* + * {"kty":"RSA", + * "use":"sig", + * "kid":"KEYID", + * "x5t":"KEYID", + * "n": + * "", + * "e":"", + * "x5c":[ + * "KEYID" + * ], + * "issuer":"http://localhost/lightweight"}, + */ + private void getKeys(RoutingContext rc) { + RSAPublicKey pub = (RSAPublicKey) kp.getPublic(); + String modulus = Base64.getUrlEncoder().encodeToString(pub.getModulus().toByteArray()); + String exponent = Base64.getUrlEncoder().encodeToString(pub.getPublicExponent().toByteArray()); + String data = "{\n" + + " \"keys\": [\n" + + " {\n" + + " \"alg\": \"RS256\",\n" + + " \"kty\": \"RSA\",\n" + + " \"n\": \"" + modulus + "\",\n" + + " \"use\": \"sig\",\n" + + " \"kid\": \"KEYID\",\n" + + " \"k5t\": \"KEYID\",\n" + + " \"issuer\": \"" + baseURI + "/lightweight\",\n" + + " \"e\": \"" + exponent + "\"\n" + + " },\n" + + " ]\n" + + "}"; + rc.response() + .putHeader("Content-Type", "application/json") + .endAndForget(data); + } + + /* + * /logout + * ?post_logout_redirect_uri=URI + * &id_token_hint=SECRET + */ + private void logout(RoutingContext rc) { + // we have no cookie state + String redirect_uri = rc.request().params().get("post_logout_redirect_uri"); + rc.response() + .putHeader("Location", redirect_uri) + .setStatusCode(302) + .endAndForget(); + } +} From 1d64ff0e65f0dceefed3470d5b2023f5312a7c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 11 Aug 2023 16:25:42 +0200 Subject: [PATCH 2/5] OIDC Dev UI: support session cookies --- .../keycloak/KeycloakDevUIProcessor.java | 16 ++++ .../resources/dev-ui/qwc-oidc-provider.js | 81 +++++++++++++++---- .../OidcDevSessionCookieReaderHandler.java | 45 +++++++++++ .../devui/OidcDevSessionLogoutHandler.java | 26 ++++++ .../oidc/runtime/devui/OidcDevUiRecorder.java | 10 +++ 5 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java index 544b27569da94..8cd1673a9fc40 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java @@ -24,6 +24,7 @@ import io.quarkus.oidc.runtime.devui.OidcDevJsonRpcService; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; +import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.HttpConfiguration; public class KeycloakDevUIProcessor extends AbstractDevUIProcessor { @@ -80,4 +81,19 @@ void produceProviderComponent(Optional confi JsonRPCProvidersBuildItem produceOidcDevJsonRpcService() { return new JsonRPCProvidersBuildItem(OidcDevJsonRpcService.class); } + + @Record(ExecutionTime.RUNTIME_INIT) + @BuildStep(onlyIf = IsDevelopment.class) + void invokeEndpoint(BuildProducer routeProducer, + OidcDevUiRecorder recorder, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "readSessionCookie") + .handler(recorder.readSessionCookieHandler()) + .build()); + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "logout") + .handler(recorder.logoutHandler()) + .build()); + } } diff --git a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js index 3948bec35d168..d8f68d9ba8ed8 100644 --- a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js +++ b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js @@ -366,7 +366,8 @@ export class QwcOidcProvider extends QwcHotReloadElement { _webAppLoginCard() { const servicePathForm = this._servicePathForm(); return html` - + ${servicePathForm} + ${this._displayTokenCard()} `; } @@ -640,8 +642,6 @@ export class QwcOidcProvider extends QwcHotReloadElement { _implicitOrCodeGrantTypeCard() { const keycloakRealms = this._keycloakRealmsForm(); - const servicePathForm = this._servicePathForm(); - const testServiceResultsHtml = QwcOidcProvider._testServiceResultsHtml(); return html` + ${this._displayTokenCard()} + `; + } + + _displayTokenCard() { + const servicePathForm = this._servicePathForm(); + const testServiceResultsHtml = QwcOidcProvider._testServiceResultsHtml(); + return html` @@ -1010,22 +1018,29 @@ export class QwcOidcProvider extends QwcHotReloadElement { const state = QwcOidcProvider._getQueryParameter('state'); QwcOidcProvider._exchangeCodeForTokens(code, state, jsonRpc, onUpdateDone); } else { - // logged out - - propertiesState.hideImplicitLoggedIn = true; - propertiesState.userName = null; - - if (QwcOidcProvider._isErrorInUrl()) { + QwcOidcProvider._checkSessionCookie(jsonRpc, () => { + // logged in propertiesState.hideImplLoggedOut = true; - propertiesState.hideLogInErr = false; - } else { propertiesState.hideLogInErr = true; - propertiesState.hideImplLoggedOut = false; - } - - propertiesState.accessToken = null; - propertiesState.idToken = null; - onUpdateDone(); + propertiesState.hideImplicitLoggedIn = false; + onUpdateDone(); + }, () => { + // logged out + propertiesState.hideImplicitLoggedIn = true; + propertiesState.userName = null; + + if (QwcOidcProvider._isErrorInUrl()) { + propertiesState.hideImplLoggedOut = true; + propertiesState.hideLogInErr = false; + } else { + propertiesState.hideLogInErr = true; + propertiesState.hideImplLoggedOut = false; + } + + propertiesState.accessToken = null; + propertiesState.idToken = null; + onUpdateDone(); + }); } } @@ -1102,6 +1117,38 @@ export class QwcOidcProvider extends QwcHotReloadElement { } } + static _checkSessionCookie(jsonRpc, onLoggedIn, onLoggedOut) { + // FIXME: port, path? + fetch("http://localhost:8080/q/io.quarkus.quarkus-oidc/readSessionCookie") + .then(response => response.json()) + .then(result => { + if ("id_token" in result || "access_token" in result) { + const tokens = result; + const hasIdToken = "id_token" in tokens; + propertiesState.userName = QwcOidcProvider._parseUserName(tokens.access_token, + hasIdToken ? tokens.id_token : null); + + propertiesState.accessToken = tokens.access_token; + + if (hasIdToken) { + propertiesState.idToken = tokens.id_token; + } else { + propertiesState.idToken = null; + } + propertiesState.logoutUrl = "http://localhost:8080/q/io.quarkus.quarkus-oidc/logout"; + propertiesState.postLogoutUriParam = "redirect_uri"; + onLoggedIn(); + } else { + onLoggedOut(); + } + }) + .catch(response => { + notifier.showErrorMessage('Failed to exchange code for tokens. Error message: ' + + response?.error?.message, 'top-end'); + onLoggedOut(); + }); + } + static _getTokenForNavigation() { if (propertiesState.introspectionIsAvailable) { return propertiesState.accessToken; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java new file mode 100644 index 0000000000000..32401f5bb49f2 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java @@ -0,0 +1,45 @@ +package io.quarkus.oidc.runtime.devui; + +import java.util.regex.Pattern; + +import org.jboss.logging.Logger; + +import io.quarkus.arc.Arc; +import io.quarkus.oidc.AuthorizationCodeTokens; +import io.quarkus.oidc.runtime.DefaultTokenStateManager; +import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.oidc.runtime.OidcUtils; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Handler; +import io.vertx.core.http.Cookie; +import io.vertx.ext.web.RoutingContext; + +public class OidcDevSessionCookieReaderHandler implements Handler { + private static final Logger LOG = Logger.getLogger(OidcDevSessionCookieReaderHandler.class); + static final String COOKIE_DELIM = "|"; + static final Pattern COOKIE_PATTERN = Pattern.compile("\\" + COOKIE_DELIM); + + @Override + public void handle(RoutingContext rc) { + Cookie cookie = rc.request().getCookie(OidcUtils.SESSION_COOKIE_NAME); + if (cookie != null) { + DefaultTokenStateManager tokenStateManager = Arc.container().instance(DefaultTokenStateManager.class).get(); + OidcConfig oidcConfig = Arc.container().instance(OidcConfig.class).get(); + Uni tokensUni = tokenStateManager.getTokens(rc, oidcConfig.defaultTenant, + cookie.getValue(), null); + tokensUni.subscribe().with(tokens -> { + rc.response().setStatusCode(200); + rc.response().putHeader("Content-Type", "application/json"); + rc.end("{\"id_token\": \"" + tokens.getIdToken() + "\", \"access_token\": \"" + tokens.getAccessToken() + + "\", \"refresh_token\": \"" + + tokens.getRefreshToken() + + "\"}"); + }); + } else { + rc.response().setStatusCode(200); + rc.response().putHeader("Content-Type", "application/json"); + // empty: not logged in + rc.end("{}"); + } + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java new file mode 100644 index 0000000000000..d6ae3cb7c8cb5 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java @@ -0,0 +1,26 @@ +package io.quarkus.oidc.runtime.devui; + +import org.jboss.logging.Logger; + +import io.quarkus.oidc.runtime.OidcUtils; +import io.vertx.core.Handler; +import io.vertx.core.http.impl.ServerCookie; +import io.vertx.ext.web.RoutingContext; + +public class OidcDevSessionLogoutHandler implements Handler { + private static final Logger LOG = Logger.getLogger(OidcDevSessionLogoutHandler.class); + + @Override + public void handle(RoutingContext rc) { + String redirect = rc.request().getParam("redirect_uri"); + ServerCookie cookie = (ServerCookie) rc.request().getCookie(OidcUtils.SESSION_COOKIE_NAME); + if (cookie != null) { + cookie.setValue(""); + cookie.setMaxAge(0L); + cookie.setPath("/"); + } + rc.response().setStatusCode(302); + rc.response().putHeader("Location", redirect); + rc.response().end(); + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java index 7cec424c34251..5c31936dbdfb1 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java @@ -8,6 +8,8 @@ import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; @Recorder public class OidcDevUiRecorder { @@ -30,4 +32,12 @@ public RuntimeValue getRpcServiceProperties(Strin introspectionIsAvailable, keycloakAdminUrl, keycloakRealms, swaggerIsAvailable, graphqlIsAvailable, swaggerUiPath, graphqlUiPath, alwaysLogoutUserInDevUiOnReload)); } + + public Handler readSessionCookieHandler() { + return new OidcDevSessionCookieReaderHandler(); + } + + public Handler logoutHandler() { + return new OidcDevSessionLogoutHandler(); + } } From 124ac6f58ba3df3432119d1d4c48117e8322758b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 11 Aug 2023 16:26:55 +0200 Subject: [PATCH 3/5] OIDC Dev UI: support UTF-8 in JWT --- .../src/main/resources/dev-ui/qwc-oidc-provider.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js index d8f68d9ba8ed8..b597738bc90b4 100644 --- a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js +++ b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js @@ -1233,12 +1233,20 @@ export class QwcOidcProvider extends QwcHotReloadElement { return html``; } + static _decodeBase64(encoded){ + function base64ToBytes(base64) { + const binString = window.atob(base64); + return Uint8Array.from(binString, (m) => m.codePointAt(0)); + } + return new TextDecoder().decode(base64ToBytes(encoded)); + } + static _decodeToken(token) { if (token) { const parts = token.split("."); if (parts.length === 3) { - const headers = window.atob(parts[0]); - const payload = window.atob(parts[1]); + const headers = QwcOidcProvider._decodeBase64(parts[0]); + const payload = QwcOidcProvider._decodeBase64(parts[1]); const signature = parts[2]; const jsonPayload = JSON.parse(payload); return html` @@ -1272,7 +1280,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { if (token) { const parts = token.split("."); if (parts.length === 3) { - const payload = window.atob(parts[1]); + const payload = QwcOidcProvider._decodeBase64(parts[1]); const jsonPayload = JSON.parse(payload); if (jsonPayload?.upn) { return jsonPayload.upn; From e24bd403c3829999a9b3f378adcfbc57214be165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 11 Aug 2023 16:27:11 +0200 Subject: [PATCH 4/5] OIDC Dev UI: decorate decoded JWT with tooltips --- .../resources/dev-ui/qwc-oidc-provider.js | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js index b597738bc90b4..2b1e92e9f9539 100644 --- a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js +++ b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js @@ -1,5 +1,6 @@ import { QwcHotReloadElement, html, css} from 'qwc-hot-reload-element'; import {classMap} from 'lit/directives/class-map.js'; +import {unsafeHTML} from 'lit/directives/unsafe-html.js'; import {JsonRpc} from 'jsonrpc'; import { LitState } from 'lit-element-state'; import '@vaadin/button'; @@ -231,6 +232,10 @@ export class QwcOidcProvider extends QwcHotReloadElement { .half-width { width: 50%; } + .jwt-tooltip { + cursor: help; + background: rgba(0, 0, 0, .1); + } `; jsonRpc = new JsonRpc(this); @@ -1241,6 +1246,52 @@ export class QwcOidcProvider extends QwcHotReloadElement { return new TextDecoder().decode(base64ToBytes(encoded)); } + static _formatJson(jwt) { + const tooltips = { + "iss": "Issuer", + "sub": "Subject", + "aud": "Audience", + "nbf": "Not Before", + "iat": "Issued At", + "exp": "Expiration Time", + "jti": "JWT ID" + }; + const spaces = 4; + var ret = "{"; + var once = false; + for(let k in jwt){ + if (Object.prototype.hasOwnProperty.call(jwt, k)) { + const val = jwt[k]; + if(once){ + ret += ","; + } + ret += "\n" + " ".repeat(spaces); + // decorate key + var tooltip = tooltips[k]; + if(tooltip) { + ret += "\"" + k + "\""; + } else { + ret += "\"" + k + "\""; + } + // on to values + ret += ": "; + // decorate values + if(k == 'iat' || k == 'nbf' || k == 'exp'){ + ret += "" + val + ""; + } else { + ret += JSON.stringify(val); + } + + } + once = true; + } + if(once){ + ret += "\n"; + } + ret += "}"; + return ret; + } + static _decodeToken(token) { if (token) { const parts = token.split("."); @@ -1249,10 +1300,11 @@ export class QwcOidcProvider extends QwcHotReloadElement { const payload = QwcOidcProvider._decodeBase64(parts[1]); const signature = parts[2]; const jsonPayload = JSON.parse(payload); + const json = QwcOidcProvider._formatJson(jsonPayload); return html`
${JSON.stringify(JSON.parse(headers), null, 4)?.trim()}
-
${JSON.stringify(jsonPayload,null,4)?.trim()}
-- ${signature?.trim()} +
${unsafeHTML(json?.trim())}
+ ${signature?.trim()} `; } else if (parts.length === 5) { const headers = window.atob(parts[0]?.trim()); From 0461ec38c8ccfca21db6c6d9627a3733192487a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Tue, 14 Jan 2025 13:27:43 +0100 Subject: [PATCH 5/5] Finalize Dev Services for OIDC --- .github/native-tests.json | 2 +- bom/application/pom.xml | 5 + ...ui-oidc-dev-svc-login-for-custom-users.png | Bin 0 -> 11283 bytes .../images/dev-ui-oidc-dev-svc-login-page.png | Bin 0 -> 7044 bytes .../security-openid-connect-dev-services.adoc | 71 +- .../keycloak/KeycloakDevServicesConfig.java | 6 - .../KeycloakDevServicesProcessor.java | 7 +- extensions/devservices/oidc/pom.xml | 53 + .../oidc/OidcDevServicesConfig.java | 35 + .../oidc/OidcDevServicesConfigBuildItem.java} | 10 +- .../oidc/OidcDevServicesProcessor.java | 932 ++++++++++++++++++ extensions/devservices/pom.xml | 1 + extensions/oidc/deployment/pom.xml | 4 + .../devservices/AbstractDevUIProcessor.java | 38 + .../devservices/OidcDevUIProcessor.java | 29 +- .../keycloak/KeycloakDevUIProcessor.java | 28 +- .../LightweightDevServicesProcessor.java | 628 ------------ .../resources/dev-ui/qwc-oidc-provider.js | 186 ++-- .../runtime/devui/OidcDevJsonRpcService.java | 9 + .../runtime/devui/OidcDevLoginObserver.java | 70 ++ .../OidcDevSessionCookieReaderHandler.java | 23 +- .../devui/OidcDevSessionLogoutHandler.java | 5 +- .../oidc/runtime/devui/OidcDevUiRecorder.java | 10 +- integration-tests/oidc-dev-services/pom.xml | 99 ++ .../it/oidc/dev/services/SecuredResource.java | 59 ++ .../src/main/resources/application.properties | 3 + ...BearerAuthenticationOidcDevServicesIT.java | 8 + ...arerAuthenticationOidcDevServicesTest.java | 77 ++ .../services/CodeFlowOidcDevServicesTest.java | 127 +++ integration-tests/pom.xml | 1 + 30 files changed, 1752 insertions(+), 774 deletions(-) create mode 100644 docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-for-custom-users.png create mode 100644 docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-page.png create mode 100644 extensions/devservices/oidc/pom.xml create mode 100644 extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java rename extensions/{oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java => devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java} (50%) create mode 100644 extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java delete mode 100644 extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java create mode 100644 integration-tests/oidc-dev-services/pom.xml create mode 100644 integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java create mode 100644 integration-tests/oidc-dev-services/src/main/resources/application.properties create mode 100644 integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java create mode 100644 integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java create mode 100644 integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java diff --git a/.github/native-tests.json b/.github/native-tests.json index cd9b3beb494a9..ee6584b2b45f4 100644 --- a/.github/native-tests.json +++ b/.github/native-tests.json @@ -75,7 +75,7 @@ { "category": "Security2", "timeout": 75, - "test-modules": "oidc, oidc-code-flow, oidc-tenancy, oidc-client, oidc-client-reactive, oidc-token-propagation, oidc-wiremock, oidc-client-wiremock, oidc-wiremock-providers", + "test-modules": "oidc, oidc-code-flow, oidc-tenancy, oidc-client, oidc-client-reactive, oidc-token-propagation, oidc-wiremock, oidc-client-wiremock, oidc-wiremock-providers, oidc-dev-services", "os-name": "ubuntu-latest" }, { diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 853be36300924..2048429f2fb7e 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1099,6 +1099,11 @@ quarkus-devservices-keycloak ${project.version} + + io.quarkus + quarkus-devservices-oidc + ${project.version} + io.quarkus quarkus-flyway diff --git a/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-for-custom-users.png b/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-for-custom-users.png new file mode 100644 index 0000000000000000000000000000000000000000..f82a9733a4ca9efd3eb593482f7ab9b1c4ce728c GIT binary patch literal 11283 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 literal 0 HcmV?d00001 diff --git a/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-page.png b/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-page.png new file mode 100644 index 0000000000000000000000000000000000000000..7d923c72623e0f873773555a26aa79d13d5785ac GIT binary patch literal 7044 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 kn(mo@oB!iI_;`2}1Fz-X+OnDhUrqr!4i_@% literal 0 HcmV?d00001 diff --git a/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc index dcdbdbeac0415..538f7c1b7e040 100644 --- a/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc @@ -21,11 +21,12 @@ The Dev Services for Keycloak feature starts a Keycloak container for both the d It initializes them by registering the existing Keycloak realm or creating a new realm with the client and users required for you to start developing your Quarkus application secured by Keycloak immediately. The container restarts when the `application.properties` or the realm file changes have been detected. -Additionally, xref:dev-ui.adoc[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. +Additionally, xref:dev-ui.adoc[Dev UI] available at http://localhost:8080/q/dev-ui/extensions[/q/dev-ui/extensions] 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, is activated. For more information, see <>. +[[dev-services-for-keycloak]] == Dev Services for Keycloak Start your application without configuring `quarkus.oidc` properties in the `application.properties` file: @@ -221,33 +222,6 @@ image::dev-ui-keycloak-sign-in-to-service.png[alt=Dev UI OpenID Connect Keycloak Set a relative service endpoint path and click *Log in to your web application*. You are redirected to Keycloak to enter a username and password in a new browser tab before you get a response from the Quarkus application. -In this case, the Dev UI is not very helpful because the Quarkus OIDC `web-app` application controls the authorization code flow and acquires the tokens. - -To make Dev UI more helpful in supporting the development of OIDC `web-app` applications, consider setting profile-specific values for `quarkus.oidc.application-type`: - -[source,properties] ----- -%prod.quarkus.oidc.application-type=web-app -%test.quarkus.oidc.application-type=web-app -%dev.quarkus.oidc.application-type=service ----- - -This profile ensures that all Dev UI options described in <> are available when your `web-app` application is run in dev mode. -The limitation of this approach is that both access and ID tokens returned with the code flow and acquired with Dev UI are sent to the endpoint as HTTP `Bearer` tokens - which does not work well if your endpoint requires the injection of `IdToken`. -However, it works as expected if your `web-app` application only uses the access token, for example, as a source of roles or to get `UserInfo`, even if it is assumed to be a `service` application in dev mode. - -For dev mode, an even better option is to set the `application-type` property to `hybrid`: - -[source,properties] ----- -%prod.quarkus.oidc.application-type=web-app -%test.quarkus.oidc.application-type=web-app -%dev.quarkus.oidc.application-type=hybrid ----- - -This type ensures that if you access the application from the browser in dev mode without the OIDC Dev UI, Quarkus OIDC also performs the authorization code flow as in the production mode. -The OIDC Dev UI is also more beneficial because hybrid applications can also accept the bearer access tokens. - === Running the tests You can run the tests against a Keycloak container started in a test mode in a xref:continuous-testing.adoc[Continuous Testing] mode. @@ -406,6 +380,47 @@ This document refers to the `http://localhost:8080/q/dev-ui` Dev UI URL in sever If you customize `quarkus.http.root-path` or `quarkus.http.non-application-root-path` properties, then replace `q` accordingly. For more information, see the https://quarkus.io/blog/path-resolution-in-quarkus/[Path resolution in Quarkus] blog post. +== Dev Services for OIDC + +When you work with Keycloak in production, <> provides the best dev mode experience. +For other OpenID Connect providers, it is recommended to enable the Dev Services for OIDC like in the example below: + +[source,properties] +---- +quarkus.oidc.devservices.enabled=true +---- + +NOTE: the Dev Services for OIDC are enabled by default if Docker and Podman are not available. + +Once enabled, Quarkus starts a new OIDC server that supports most common OpenID Connect operations. +You can confirm in your console that the OIDC server started, you will see output similar to the following: + +[source,shell] +---- +2025-01-08 20:50:20,900 INFO [io.qua.dev.oid.OidcDevServicesProcessor] (build-16) Dev Services for OIDC started on http://localhost:38139 +---- + +If you navigate to the <>, you can log into the OIDC server as builtin users `alice` or `bob`: + +image::dev-ui-oidc-dev-svc-login-page.png[alt=Dev Services for OIDC builtin user login,role="center"] + +This login page is also displayed if you navigate to authenticated request path during the development of the xref:security-oidc-code-flow-authentication.adoc[Quarkus OIDC web application]. +As always, the default roles for `alice` are `admin` and `user`, while the roles for `bob` are just `user`. +You can configure those built-in roles if required: + +[source,properties] +---- +quarkus.oidc.devservices.roles.alice=root <1> +quarkus.oidc.devservices.roles.bob=guest +---- +<1> Assign a `root` role to the user `alice`. + +Another option is log in as a custom user with the username and roles of your choice: + +image::dev-ui-oidc-dev-svc-login-for-custom-users.png[alt=Dev Services for OIDC custom user login,role="center"] + +Whichever user you choose, no password is required. + == References * xref:dev-ui.adoc[Dev UI] diff --git a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java index 20857559b3c47..9a0087eea9e26 100644 --- a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java +++ b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java @@ -28,12 +28,6 @@ public interface KeycloakDevServicesConfig { @WithDefault("true") boolean enabled(); - /** - * Use lightweight dev services instead of Keycloak - */ - @ConfigItem(defaultValue = "false") - public boolean lightweight; - /** * The container image name for Dev Services providers. * diff --git a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java index e9791b27491be..5c537f0a14514 100644 --- a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java +++ b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java @@ -145,7 +145,8 @@ DevServicesResultBuildItem startKeycloakContainer( DevServicesConfig devServicesConfig, DockerStatusBuildItem dockerStatusBuildItem) { if (devSvcRequiredMarkerItems.isEmpty() - || linuxContainersNotAvailable(dockerStatusBuildItem, devSvcRequiredMarkerItems)) { + || linuxContainersNotAvailable(dockerStatusBuildItem, devSvcRequiredMarkerItems) + || oidcDevServicesEnabled()) { if (devService != null) { closeDevService(); } @@ -248,6 +249,10 @@ public void run() { return devService.toBuildItem(); } + private static boolean oidcDevServicesEnabled() { + return ConfigProvider.getConfig().getOptionalValue("quarkus.oidc.devservices.enabled", boolean.class).orElse(false); + } + private static boolean linuxContainersNotAvailable(DockerStatusBuildItem dockerStatusBuildItem, List devSvcRequiredMarkerItems) { if (dockerStatusBuildItem.isContainerRuntimeAvailable()) { diff --git a/extensions/devservices/oidc/pom.xml b/extensions/devservices/oidc/pom.xml new file mode 100644 index 0000000000000..ec198748674a6 --- /dev/null +++ b/extensions/devservices/oidc/pom.xml @@ -0,0 +1,53 @@ + + + + quarkus-devservices-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-devservices-oidc + Quarkus - DevServices - OIDC + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-devservices-common + + + io.smallrye.reactive + smallrye-mutiny-vertx-web + + + io.smallrye + smallrye-jwt-build + + + + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + diff --git a/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java new file mode 100644 index 0000000000000..e97eef86dad8d --- /dev/null +++ b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java @@ -0,0 +1,35 @@ +package io.quarkus.devservices.oidc; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigDocDefault; +import io.quarkus.runtime.annotations.ConfigDocMapKey; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; + +/** + * OpenID Connect Dev Services configuration. + */ +@ConfigRoot +@ConfigMapping(prefix = "quarkus.oidc.devservices") +public interface OidcDevServicesConfig { + + /** + * Use OpenID Connect Dev Services instead of Keycloak. + */ + @ConfigDocDefault("Enabled when Docker and Podman are not available") + Optional enabled(); + + /** + * A map of roles for OIDC identity provider users. + *

+ * If empty, default roles are assigned: `alice` receives `admin` and `user` roles, while other users receive + * `user` role. + * This map is used for role creation when no realm file is found at the `realm-path`. + */ + @ConfigDocMapKey("role-name") + Map> roles(); + +} diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java similarity index 50% rename from extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java rename to extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java index 30d9fac042b9b..14fc63be89258 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java +++ b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java @@ -1,18 +1,22 @@ -package io.quarkus.oidc.deployment.devservices.keycloak; +package io.quarkus.devservices.oidc; import java.util.Map; import io.quarkus.builder.item.SimpleBuildItem; -public final class LightweightDevServicesConfigBuildItem extends SimpleBuildItem { +/** + * OIDC Dev Services configuration properties. + */ +public final class OidcDevServicesConfigBuildItem extends SimpleBuildItem { private final Map config; - public LightweightDevServicesConfigBuildItem(Map config) { + OidcDevServicesConfigBuildItem(Map config) { this.config = config; } public Map getConfig() { return config; } + } diff --git a/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java new file mode 100644 index 0000000000000..1729e3ad64e70 --- /dev/null +++ b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java @@ -0,0 +1,932 @@ +package io.quarkus.devservices.oidc; + +import static io.quarkus.deployment.bean.JavaBeanUtil.capitalize; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.jwt.Claims; +import org.jboss.logging.Logger; +import org.jose4j.base64url.Base64Url; + +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem.RunningDevService; +import io.quarkus.deployment.builditem.DockerStatusBuildItem; +import io.quarkus.deployment.dev.devservices.DevServicesConfig; +import io.quarkus.runtime.configuration.ConfigUtils; +import io.smallrye.jwt.build.Jwt; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.json.JsonObject; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.core.http.HttpServer; +import io.vertx.mutiny.ext.web.Router; +import io.vertx.mutiny.ext.web.RoutingContext; +import io.vertx.mutiny.ext.web.handler.BodyHandler; + +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = DevServicesConfig.Enabled.class) +public class OidcDevServicesProcessor { + + private static final Logger LOG = Logger.getLogger(OidcDevServicesProcessor.class); + private static final String CONFIG_PREFIX = "quarkus.oidc."; + private static final String OIDC_ENABLED = CONFIG_PREFIX + "enabled"; + 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 PROVIDER_CONFIG_KEY = CONFIG_PREFIX + "provider"; + private static final String APPLICATION_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; + private static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; + private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret"; + + private static volatile KeyPair kp; + private static volatile String kid; + private static volatile String baseURI; + private static volatile String clientId; + private static volatile String clientSecret; + private static volatile String applicationType; + private static volatile Map configProperties; + private static volatile Map> userToDefaultRoles; + private static volatile Runnable closeDevServiceTask; + + @BuildStep + DevServicesResultBuildItem startServer(CuratedApplicationShutdownBuildItem closeBuildItem, + OidcDevServicesConfig devServicesConfig, DockerStatusBuildItem dockerStatusBuildItem, + BuildProducer devServiceConfigProducer) { + if (shouldNotStartServer(devServicesConfig, dockerStatusBuildItem)) { + closeDevSvcIfNecessary(); + return null; + } + + userToDefaultRoles = devServicesConfig.roles(); + if (closeDevServiceTask == null) { + LOG.info("Starting Dev Services for OIDC"); + Vertx vertx = Vertx.vertx(); + HttpServerOptions options = new HttpServerOptions(); + options.setPort(0); + HttpServer httpServer = vertx.createHttpServer(options); + + Router router = Router.router(vertx); + httpServer.requestHandler(router); + registerRoutes(router); + + httpServer.listenAndAwait(); + baseURI = "http://localhost:" + httpServer.actualPort(); + closeDevServiceTask = new Runnable() { + + private volatile boolean closed = false; + + @Override + public void run() { + if (closed) { + return; + } + closed = true; + // this is done on delegates because closing Mutiny wrapper can result in unrelated exception + // when other tests (not necessarily using this dev services) run after a test using this service + httpServer.getDelegate().close(httpServerResult -> { + if (httpServerResult != null && httpServerResult.failed()) { + LOG.error("Failed to close HTTP Server", httpServerResult.cause()); + } + vertx.getDelegate().close(vertxResult -> { + if (vertxResult != null && vertxResult.failed()) { + LOG.error("Failed to close Vertx instance", vertxResult.cause()); + } + }); + }); + } + }; + closeBuildItem.addCloseTask(OidcDevServicesProcessor::closeDevSvcIfNecessary, true); + updateDevSvcConfigProperties(); + LOG.infof("Dev Services for OIDC started on %s", baseURI); + } else if (!getOidcClientId().equals(clientId) || !getOidcApplicationType().equals(applicationType)) { + updateDevSvcConfigProperties(); + } + + devServiceConfigProducer.produce(new OidcDevServicesConfigBuildItem(configProperties)); + return new RunningDevService("oidc-dev-services", null, () -> { + }, configProperties).toBuildItem(); + } + + private static void closeDevSvcIfNecessary() { + if (closeDevServiceTask != null) { + closeDevServiceTask.run(); + closeDevServiceTask = null; + } + } + + private static boolean shouldNotStartServer(OidcDevServicesConfig devServicesConfig, + DockerStatusBuildItem dockerStatusBuildItem) { + boolean explicitlyDisabled = devServicesConfig.enabled().isPresent() && !devServicesConfig.enabled().get(); + if (explicitlyDisabled) { + LOG.debug("Not starting Dev Services for OIDC as it has been disabled in the config"); + return true; + } + if (devServicesConfig.enabled().isEmpty() && dockerStatusBuildItem.isContainerRuntimeAvailable()) { + LOG.debug("Not starting Dev Services for OIDC as detected support the container functionality"); + return true; + } + if (!isOidcEnabled()) { + LOG.debug("Not starting Dev Services for OIDC as OIDC extension has been disabled in the config"); + return true; + } + if (!isOidcTenantEnabled()) { + LOG.debug("Not starting Dev Services for OIDC as 'quarkus.oidc.tenant.enabled' is false"); + return true; + } + if (ConfigUtils.isPropertyPresent(AUTH_SERVER_URL_CONFIG_KEY)) { + LOG.debug("Not starting Dev Services for OIDC as 'quarkus.oidc.auth-server-url' has been provided"); + return true; + } + if (ConfigUtils.isPropertyPresent(PROVIDER_CONFIG_KEY)) { + LOG.debug("Not starting Dev Services for OIDC as 'quarkus.oidc.provider' has been provided"); + return true; + } + return false; + } + + private static void updateDevSvcConfigProperties() { + // relevant configuration has changed + clientId = getOidcClientId(); + clientSecret = getOidcClientSecret(); + applicationType = getOidcApplicationType(); + final Map aConfigProperties = new HashMap<>(); + aConfigProperties.put(AUTH_SERVER_URL_CONFIG_KEY, baseURI); + aConfigProperties.put(APPLICATION_TYPE_CONFIG_KEY, applicationType); + aConfigProperties.put(CLIENT_ID_CONFIG_KEY, clientId); + aConfigProperties.put(CLIENT_SECRET_CONFIG_KEY, clientSecret); + configProperties = Map.copyOf(aConfigProperties); + } + + private static void registerRoutes(Router router) { + BodyHandler bodyHandler = BodyHandler.create(); + router.get("/").handler(OidcDevServicesProcessor::mainRoute); + router.get("/.well-known/openid-configuration").handler(OidcDevServicesProcessor::configuration); + router.get("/authorize").handler(OidcDevServicesProcessor::authorize); + router.post("/login").handler(bodyHandler).handler(OidcDevServicesProcessor::login); + router.post("/token").handler(bodyHandler).handler(OidcDevServicesProcessor::token); + router.get("/keys").handler(OidcDevServicesProcessor::getKeys); + router.get("/logout").handler(OidcDevServicesProcessor::logout); + router.get("/userinfo").handler(OidcDevServicesProcessor::userInfo); + + // can be used for testing of bearer token authentication + router.get("/testing/generate/access-token").handler(OidcDevServicesProcessor::generateAccessToken); + + KeyPairGenerator kpg; + try { + kpg = KeyPairGenerator.getInstance("RSA"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + kpg.initialize(2048); + kp = kpg.generateKeyPair(); + kid = createKeyId(); + } + + private static void generateAccessToken(RoutingContext rc) { + String user = rc.request().getParam("user"); + if (user == null || user.isEmpty()) { + rc.response().setStatusCode(400).endAndForget("Missing required parameter: user"); + return; + } + String rolesParam = rc.request().getParam("roles"); + Set roles = new HashSet<>(); + if (rolesParam == null || rolesParam.isEmpty()) { + roles.addAll(getUserRoles(user)); + } else { + roles.addAll(Arrays.asList(rolesParam.split(","))); + } + rc.response().endAndForget(createAccessToken(user, roles, Set.of("openid", "email"))); + } + + private static List getUsers() { + if (userToDefaultRoles.isEmpty()) { + return Arrays.asList("alice", "bob"); + } else { + List ret = new ArrayList<>(userToDefaultRoles.keySet()); + Collections.sort(ret); + return ret; + } + } + + private static List getUserRoles(String user) { + List roles = userToDefaultRoles.get(user); + return roles == null ? ("alice".equals(user) ? List.of("admin", "user") : List.of("user")) + : roles; + } + + private static boolean isOidcEnabled() { + return ConfigProvider.getConfig().getValue(OIDC_ENABLED, Boolean.class); + } + + private static boolean isOidcTenantEnabled() { + return ConfigProvider.getConfig().getOptionalValue(TENANT_ENABLED_CONFIG_KEY, Boolean.class).orElse(true); + } + + private static String getOidcApplicationType() { + return ConfigProvider.getConfig().getOptionalValue(APPLICATION_TYPE_CONFIG_KEY, String.class).orElse("service"); + } + + private static String getOidcClientId() { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_ID_CONFIG_KEY, String.class) + .orElse("quarkus-app"); + } + + private static String getOidcClientSecret() { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class) + .orElseGet(() -> UUID.randomUUID().toString()); + } + + private static void mainRoute(RoutingContext rc) { + rc.response().endAndForget("OIDC server up and running"); + } + + private static void configuration(RoutingContext rc) { + String data = """ + { + "token_endpoint":"%1$s/token", + "token_endpoint_auth_methods_supported":[ + "client_secret_post", + "private_key_jwt", + "client_secret_basic" + ], + "jwks_uri":"%1$s/keys", + "response_modes_supported":[ + "query" + ], + "subject_types_supported":[ + "pairwise" + ], + "id_token_signing_alg_values_supported":[ + "RS256" + ], + "response_types_supported":[ + "code", + "id_token", + "code id_token", + "id_token token", + "code id_token token" + ], + "scopes_supported":[ + "openid", + "profile", + "email", + "offline_access" + ], + "issuer":"%1$s", + "request_uri_parameter_supported":false, + "userinfo_endpoint":"%1$s/userinfo", + "authorization_endpoint":"%1$s/authorize", + "device_authorization_endpoint":"%1$s/devicecode", + "http_logout_supported":true, + "frontchannel_logout_supported":true, + "end_session_endpoint":"%1$s/logout", + "claims_supported":[ + "sub", + "iss", + "aud", + "exp", + "iat", + "auth_time", + "acr", + "nonce", + "preferred_username", + "name", + "tid", + "ver", + "at_hash", + "c_hash", + "email" + ] + } + """.formatted(baseURI); + rc.response().putHeader("Content-Type", "application/json"); + rc.endAndForget(data); + } + + /* + * First request: + * GET + * https://localhost:X/authorize?response_type=code&client_id=SECRET&scope=openid+openid+ + * email+profile&redirect_uri=http://localhost:8080/Login/oidcLoginSuccess&state=STATE + * + * returns a 302 to + * GET http://localhost:8080/Login/oidcLoginSuccess?code=CODE&state=STATE + */ + private static void authorize(RoutingContext rc) { + String response_type = rc.request().params().get("response_type"); + String clientId = rc.request().params().get("client_id"); + String scope = rc.request().params().get("scope"); + String state = rc.request().params().get("state"); + String redirect_uri = rc.request().params().get("redirect_uri"); + URI redirect; + try { + redirect = new URI(redirect_uri + "?state=" + state); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + StringBuilder predefinedUsers = new StringBuilder(); + for (String predefinedUser : getUsers()) { + predefinedUsers.append(" \n"); + } + rc.response() + .endAndForget( + """ + + + Login + + + +

+
+
Login
+
+
+
+ """ + + """ + + + + + %2$s +
+
+ Custom user +
+ + + + +
+
+ +
+
+
+
+ + + """.formatted(redirect.toASCIIString(), predefinedUsers, response_type, clientId, + scope)); + } + + private static void login(RoutingContext rc) { + String redirect_uri = rc.request().params().get("redirect_uri"); + String predefined = null; + for (Map.Entry param : rc.request().params()) { + if (param.getKey().startsWith("predefined")) { + predefined = param.getValue(); + break; + } + } + String name = rc.request().params().get("name"); + String roles = rc.request().params().get("roles"); + String scope = rc.request().params().get("scope"); + String clientId = rc.request().params().get("client_id"); + String responseType = rc.request().params().get("response_type"); + + if (predefined != null) { + name = predefined; + roles = String.join(",", getUserRoles(name)); + } + if (name == null || name.isBlank()) { + name = "user"; + } + + if (responseType == null || responseType.isEmpty()) { + rc.response().setStatusCode(500).endAndForget("Illegal state - the 'response_type' parameter is required"); + return; + } + + StringBuilder queryParams = new StringBuilder(); + + if (responseType.contains("code")) { + String code = new UserAndRoles(name, roles).encode(); + queryParams.append("&code=").append(code); + } + + if (responseType.contains("idtoken")) { + String idToken = createIdToken(name, getUserRolesSet(roles), clientId); + queryParams.append("&id_token=").append(idToken); + } + + if (responseType.contains(" token")) { + String accessToken = createAccessToken(name, getUserRolesSet(roles), getScopeAsSet(scope)); + queryParams.append("&access_token=").append(accessToken); + } + + rc.response() + .putHeader("Location", redirect_uri + queryParams) + .setStatusCode(302) + .endAndForget(); + } + + private static void token(RoutingContext rc) { + String grantType = rc.request().formAttributes().get("grant_type"); + switch (grantType) { + case "authorization_code" -> authorizationCodeFlowTokenEndpoint(rc); + case "refresh_token" -> refreshTokenEndpoint(rc); + case "client_credentials" -> clientCredentialsTokenEndpoint(rc); + case "password" -> passwordTokenEndpoint(rc); + default -> rc.response() + .setStatusCode(400) + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget("Unsupported grant type: " + grantType); + } + } + + private static void passwordTokenEndpoint(RoutingContext rc) { + String scope = rc.request().formAttributes().get("scope"); + String clientId = rc.request().formAttributes().get("client_id"); + String username = rc.request().formAttributes().get("username"); + if (clientId == null || clientId.isEmpty()) { + LOG.warn("Invalid client ID, denying token request"); + invalidTokenResponse(rc); + return; + } + if (username == null || username.isEmpty()) { + LOG.warn("Invalid username, denying token request"); + invalidTokenResponse(rc); + return; + } + List userRoles = getUserRoles(username); + String accessToken = createAccessToken(username, new HashSet<>(userRoles), getScopeAsSet(scope)); + String refreshToken = new UserAndRoles(username, String.join(",", userRoles)).encode(); + String data = """ + { + "access_token":"%s", + "token_type":"Bearer", + "expires_in":3600, + "refresh_token":"%s" + } + """.formatted(accessToken, refreshToken); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + private static void clientCredentialsTokenEndpoint(RoutingContext rc) { + String scope = rc.request().formAttributes().get("scope"); + String clientId = rc.request().formAttributes().get("client_id"); + if (clientId == null || clientId.isEmpty()) { + LOG.warn("Invalid client ID, denying token request"); + invalidTokenResponse(rc); + return; + } + String accessToken = createAccessToken(clientId, new HashSet<>(getUserRoles(clientId)), getScopeAsSet(scope)); + String data = """ + { + "access_token": "%s", + "token_type": "Bearer", + "expires_in": 3600 + } + """.formatted(accessToken); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + private static void refreshTokenEndpoint(RoutingContext rc) { + String clientId = rc.request().formAttributes().get("client_id"); + String clientSecret = rc.request().formAttributes().get("client_secret"); + String scope = rc.request().formAttributes().get("scope"); + if (clientId == null || clientId.isEmpty()) { + LOG.warn("Invalid client ID, denying token refresh"); + invalidTokenResponse(rc); + return; + } + if (clientSecret == null || clientSecret.isEmpty()) { + LOG.warn("Invalid client secret, denying token refresh"); + invalidTokenResponse(rc); + return; + } + String refreshToken = rc.request().formAttributes().get("refresh_token"); + UserAndRoles userAndRoles = decode(refreshToken); + if (userAndRoles == null) { + LOG.warn("Received invalid refresh token, denying token refresh"); + invalidTokenResponse(rc); + return; + } + + String accessToken = createAccessToken(userAndRoles.user, userAndRoles.getRolesAsSet(), getScopeAsSet(scope)); + String data = """ + { + "access_token": "%s", + "token_type": "Bearer", + "refresh_token": "%s", + "expires_in": 3600 + } + """.formatted(accessToken, refreshToken); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + /* + * OIDC calls POST /token? + * grant_type=authorization_code + * &code=CODE + * &redirect_uri=URI + * + * returns: + * + * { + * "token_type":"Bearer", + * "scope":"openid email profile", + * "expires_in":3600, + * "ext_expires_in":3600, + * "access_token":TOKEN, + * "id_token":JWT + * } + * + * ID token: + * { + * "ver": "2.0", + * "iss": "http://localhost", + * "sub": "USERID", + * "aud": "CLIENTID", + * "exp": 1641906214, + * "iat": 1641819514, + * "nbf": 1641819514, + * "name": "Foo Bar", + * "preferred_username": "user@example.com", + * "oid": "OPAQUE", + * "email": "user@example.com", + * "tid": "TENANTID", + * "aio": "AZURE_OPAQUE" + * } + */ + private static void authorizationCodeFlowTokenEndpoint(RoutingContext rc) { + // TODO: check redirect_uri is same as in the initial Authorization Request + String clientId = rc.request().formAttributes().get("client_id"); + if (clientId == null || clientId.isEmpty()) { + clientId = OidcDevServicesProcessor.clientId; + } + String scope = rc.request().formAttributes().get("scope"); + + String code = rc.request().formAttributes().get("code"); + UserAndRoles userAndRoles = decode(code); + if (userAndRoles == null) { + invalidTokenResponse(rc); + return; + } + + String accessToken = createAccessToken(userAndRoles.user, userAndRoles.getRolesAsSet(), getScopeAsSet(scope)); + String idToken = createIdToken(userAndRoles.user, userAndRoles.getRolesAsSet(), clientId); + + String data = """ + { + "token_type":"Bearer", + "scope":"openid email profile", + "expires_in":3600, + "ext_expires_in":3600, + "access_token":"%s", + "id_token":"%s", + "refresh_token": "%s" + } + """.formatted(accessToken, idToken, userAndRoles.encode()); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + private static void invalidTokenResponse(RoutingContext rc) { + rc.response() + .setStatusCode(400) + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(""" + { + "error": "invalid_request" + } + """); + } + + private static String createIdToken(String user, Set roles, String clientId) { + return Jwt.claims() + .expiresIn(Duration.ofDays(1)) + .issuedAt(Instant.now()) + .issuer(baseURI) + .audience(clientId) + .subject(user) + .upn(user) + .claim("name", capitalize(user)) + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId(kid) + .sign(kp.getPrivate()); + } + + private static String createAccessToken(String user, Set roles, Set scope) { + return Jwt.claims() + .expiresIn(Duration.ofDays(1)) + .issuedAt(Instant.now()) + .issuer(baseURI) + .subject(user) + .scope(scope) + .upn(user) + .claim("name", capitalize(user)) + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId(kid) + .sign(kp.getPrivate()); + } + + /* + * {"kty":"RSA", + * "use":"sig", + * "kid":"KEYID", + * "x5t":"KEYID", + * "n": + * "", + * "e":"", + * "x5c":[ + * "KEYID" + * ], + * "issuer":"http://localhost:port"}, + */ + private static void getKeys(RoutingContext rc) { + RSAPublicKey pub = (RSAPublicKey) kp.getPublic(); + String modulus = Base64.getUrlEncoder().encodeToString(pub.getModulus().toByteArray()); + String exponent = Base64.getUrlEncoder().encodeToString(pub.getPublicExponent().toByteArray()); + String data = """ + { + "keys": [ + { + "alg": "RS256", + "kty": "RSA", + "n": "%s", + "use": "sig", + "kid": "%s", + "issuer": "%s", + "e": "%s" + } + ] + } + """.formatted(modulus, kid, baseURI, exponent); + rc.response() + .putHeader("Content-Type", "application/json") + .endAndForget(data); + } + + /* + * /logout + * ?post_logout_redirect_uri=URI + * &id_token_hint=SECRET + */ + private static void logout(RoutingContext rc) { + // we have no cookie state + String redirect_uri = rc.request().params().get("post_logout_redirect_uri"); + rc.response() + .putHeader("Location", redirect_uri) + .setStatusCode(302) + .endAndForget(); + } + + private static void userInfo(RoutingContext rc) { + var authorization = rc.request().getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + String token = authorization.substring("Bearer ".length()); + JsonObject claims = decodeJwtContent(token); + if (claims != null && claims.containsKey(Claims.preferred_username.name())) { + String data = """ + { + "preferred_username": "%1$s", + "sub": "%2$s", + "name": "%2$s", + "family_name": "%2$s", + "given_name": "%2$s", + "email": "%3$s" + } + """.formatted(claims.getString(Claims.preferred_username.name()), + claims.getString(Claims.sub.name()), claims.getString(Claims.email.name())); + rc.response() + .putHeader("Content-Type", "application/json") + .endAndForget(data); + return; + } + } + rc.response().setStatusCode(401).endAndForget("WWW-Authenticate: Bearer error=\"invalid_token\""); + } + + private static UserAndRoles decode(String encodedContent) { + if (encodedContent != null && !encodedContent.isEmpty()) { + String decodedCode = new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8); + int separator = decodedCode.indexOf('|'); + if (separator != -1) { + String user = decodedCode.substring(0, separator); + String roles = decodedCode.substring(separator + 1); + if (roles.isBlank()) { + roles = String.join(",", getUserRoles(user)); + } + return new UserAndRoles(user, roles); + } else if (getUsers().contains(decodedCode)) { + String roles = String.join(",", getUserRoles(decodedCode)); + return new UserAndRoles(decodedCode, roles); + } + } + return null; + } + + private static JsonObject decodeJwtContent(String jwt) { + String encodedContent = getJwtContentPart(jwt); + if (encodedContent == null) { + return null; + } + return decodeAsJsonObject(encodedContent); + } + + private static String getJwtContentPart(String jwt) { + StringTokenizer tokens = new StringTokenizer(jwt, "."); + // part 1: skip the token headers + tokens.nextToken(); + if (!tokens.hasMoreTokens()) { + return null; + } + // part 2: token content + String encodedContent = tokens.nextToken(); + + // let's check only 1 more signature part is available + if (tokens.countTokens() != 1) { + return null; + } + return encodedContent; + } + + private static String base64UrlDecode(String encodedContent) { + return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8); + } + + private static JsonObject decodeAsJsonObject(String encodedContent) { + try { + return new JsonObject(base64UrlDecode(encodedContent)); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static Set getUserRolesSet(String roles) { + if (roles == null || roles.isEmpty()) { + return Set.of(); + } + return Arrays.stream(roles.split(",")).map(String::trim).collect(Collectors.toSet()); + } + + private static Set getScopeAsSet(String scope) { + if (scope == null || scope.isEmpty()) { + return Set.of(); + } + return Arrays.stream(scope.split(" ")).collect(Collectors.toSet()); + } + + private record UserAndRoles(String user, String roles) { + + private String encode() { + // store user|roles in the code param as Base64 + return Base64.getUrlEncoder().encodeToString((user + "|" + roles).getBytes(StandardCharsets.UTF_8)); + } + + private Set getRolesAsSet() { + if (roles == null || roles.isEmpty()) { + return Set.of(); + } else { + return new HashSet<>(Arrays.asList(roles.split("[,\\s]+"))); + } + } + + } + + private static String createKeyId() { + try { + return Base64Url.encode(MessageDigest.getInstance("SHA-256").digest(kp.getPrivate().getEncoded())); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to generate key id", e); + } + } +} diff --git a/extensions/devservices/pom.xml b/extensions/devservices/pom.xml index 5f0851f718f7a..fc3e4fae0d123 100644 --- a/extensions/devservices/pom.xml +++ b/extensions/devservices/pom.xml @@ -29,6 +29,7 @@ common deployment keycloak + oidc diff --git a/extensions/oidc/deployment/pom.xml b/extensions/oidc/deployment/pom.xml index 8e8796c16474f..8f61153678894 100644 --- a/extensions/oidc/deployment/pom.xml +++ b/extensions/oidc/deployment/pom.xml @@ -54,6 +54,10 @@ io.quarkus quarkus-devservices-keycloak
+ + io.quarkus + quarkus-devservices-oidc + io.quarkus diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java index 6d6f2ae31ad8d..e7342b6f7727c 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java @@ -3,23 +3,30 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Optional; + +import org.eclipse.microprofile.config.ConfigProvider; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; +import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.builditem.ConfigurationBuildItem; import io.quarkus.devui.spi.page.CardPageBuildItem; import io.quarkus.devui.spi.page.Page; +import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; import io.quarkus.oidc.runtime.devui.OidcDevUiRpcSvcPropertiesBean; import io.quarkus.runtime.RuntimeValue; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; +import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.smallrye.config.ConfigValue; public abstract class AbstractDevUIProcessor { protected static final String CONFIG_PREFIX = "quarkus.oidc."; protected static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; + private static final String APP_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; protected static CardPageBuildItem createProviderWebComponent(OidcDevUiRecorder recorder, Capabilities capabilities, @@ -112,4 +119,35 @@ private static String getProperty(ConfigurationBuildItem configurationBuildItem, return configValue != null ? configValue.getValue() : null; } + + protected static String getApplicationType() { + return getApplicationType(null); + } + + protected static String getApplicationType(OidcTenantConfig providerConfig) { + Optional appType = ConfigProvider.getConfig() + .getOptionalValue(APP_TYPE_CONFIG_KEY, + io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType.class); + if (appType.isEmpty() && providerConfig != null) { + appType = providerConfig.applicationType(); + } + return appType + // constant is "WEB_APP" while documented value is "web-app" and we expect users to use "web-app" + // if this get changed, we need to update qwc-oidc-provider.js as well + .map(at -> io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType.WEB_APP == at ? "web-app" + : at.name().toLowerCase()) + .orElse(OidcTenantConfig.ApplicationType.SERVICE.name().toLowerCase()); + } + + protected static void registerOidcWebAppRoutes(BuildProducer routeProducer, OidcDevUiRecorder recorder, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "readSessionCookie") + .handler(recorder.readSessionCookieHandler()) + .build()); + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "logout") + .handler(recorder.logoutHandler()) + .build()); + } } diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java index def279cb26ad3..7223bdc5e018f 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java @@ -17,14 +17,13 @@ import io.quarkus.deployment.builditem.ConfigurationBuildItem; import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem; +import io.quarkus.devservices.oidc.OidcDevServicesConfigBuildItem; import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; import io.quarkus.devui.spi.page.CardPageBuildItem; import io.quarkus.oidc.OidcTenantConfig; -import io.quarkus.oidc.OidcTenantConfig.ApplicationType; import io.quarkus.oidc.OidcTenantConfig.Provider; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.deployment.OidcBuildTimeConfig; -import io.quarkus.oidc.deployment.devservices.keycloak.LightweightDevServicesConfigBuildItem; import io.quarkus.oidc.runtime.devui.OidcDevJsonRpcService; import io.quarkus.oidc.runtime.devui.OidcDevServicesUtils; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; @@ -46,9 +45,7 @@ public class OidcDevUIProcessor extends AbstractDevUIProcessor { private static final String TENANT_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "tenant-enabled"; private static final String DISCOVERY_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "discovery-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 OIDC_PROVIDER_CONFIG_KEY = "quarkus.oidc.provider"; - private static final String SERVICE_APP_TYPE = "service"; // Well-known providers @@ -69,13 +66,14 @@ void prepareOidcDevConsole(CuratedApplicationShutdownBuildItem closeBuildItem, BuildProducer cardPageProducer, ConfigurationBuildItem configurationBuildItem, OidcDevUiRecorder recorder, - Optional lightweightDevServicesConfigBuildItem) { - if (!isOidcTenantEnabled() || (!isClientIdSet() && lightweightDevServicesConfigBuildItem.isEmpty())) { + Optional oidcDevServicesConfigBuildItem) { + if (!isOidcTenantEnabled() || (!isClientIdSet() && oidcDevServicesConfigBuildItem.isEmpty())) { return; } final OidcTenantConfig providerConfig = getProviderConfig(); - final String authServerUrl = lightweightDevServicesConfigBuildItem.isPresent() - ? lightweightDevServicesConfigBuildItem.get().getConfig().get(AUTH_SERVER_URL_CONFIG_KEY) + final boolean oidcDevServicesEnabled = oidcDevServicesConfigBuildItem.isPresent(); + final String authServerUrl = oidcDevServicesEnabled + ? oidcDevServicesConfigBuildItem.get().getConfig().get(AUTH_SERVER_URL_CONFIG_KEY) : getAuthServerUrl(providerConfig); if (authServerUrl != null) { if (vertxInstance == null) { @@ -216,19 +214,12 @@ private static String getAuthServerUrl(OidcTenantConfig providerConfig) { } } - private static String getApplicationType(OidcTenantConfig providerConfig) { - Optional appType = ConfigProvider.getConfig().getOptionalValue(APP_TYPE_CONFIG_KEY, - ApplicationType.class); - if (appType.isEmpty() && providerConfig != null) { - appType = providerConfig.applicationType; - } - return appType.isPresent() ? appType.get().name().toLowerCase() : SERVICE_APP_TYPE; - } - private static OidcTenantConfig getProviderConfig() { try { - Provider p = ConfigProvider.getConfig().getValue(OIDC_PROVIDER_CONFIG_KEY, Provider.class); - return KnownOidcProviders.provider(p); + return ConfigProvider.getConfig() + .getOptionalValue(OIDC_PROVIDER_CONFIG_KEY, Provider.class) + .map(KnownOidcProviders::provider) + .orElse(null); } catch (Exception ex) { return null; } diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java index 8cd1673a9fc40..c8468c7f002e0 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.Optional; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.IsDevelopment; @@ -22,6 +23,7 @@ import io.quarkus.oidc.deployment.OidcBuildTimeConfig; import io.quarkus.oidc.deployment.devservices.AbstractDevUIProcessor; import io.quarkus.oidc.runtime.devui.OidcDevJsonRpcService; +import io.quarkus.oidc.runtime.devui.OidcDevLoginObserver; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; @@ -55,7 +57,7 @@ void produceProviderComponent(Optional confi recorder, capabilities, "Keycloak", - configProps.get().getConfig().get("quarkus.oidc.application-type"), + getApplicationType(), oidcConfig.devui().grant().type().orElse(DevUiConfig.Grant.Type.CODE).getGrantType(), realmUrl + "/protocol/openid-connect/auth", realmUrl + "/protocol/openid-connect/token", @@ -82,18 +84,20 @@ JsonRPCProvidersBuildItem produceOidcDevJsonRpcService() { return new JsonRPCProvidersBuildItem(OidcDevJsonRpcService.class); } + @BuildStep(onlyIf = IsDevelopment.class) + AdditionalBeanBuildItem registerOidcDevLoginObserver() { + // TODO: this is called even when Keycloak DEV UI is disabled and OIDC DEV UI is enabled + // we should fine a mechanism to switch where the endpoints are registered or have shared build steps + return AdditionalBeanBuildItem.unremovableOf(OidcDevLoginObserver.class); + } + @Record(ExecutionTime.RUNTIME_INIT) @BuildStep(onlyIf = IsDevelopment.class) - void invokeEndpoint(BuildProducer routeProducer, - OidcDevUiRecorder recorder, - NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { - routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() - .nestedRoute("io.quarkus.quarkus-oidc", "readSessionCookie") - .handler(recorder.readSessionCookieHandler()) - .build()); - routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() - .nestedRoute("io.quarkus.quarkus-oidc", "logout") - .handler(recorder.logoutHandler()) - .build()); + void invokeEndpoint(BuildProducer routeProducer, OidcDevUiRecorder recorder, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { + // TODO: this is called even when Keycloak DEV UI is disabled and OIDC DEV UI is enabled + // we should fine a mechanism to switch where the endpoints are registered or have shared build steps + registerOidcWebAppRoutes(routeProducer, recorder, nonApplicationRootPathBuildItem); } + } diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java deleted file mode 100644 index c122e00272501..0000000000000 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java +++ /dev/null @@ -1,628 +0,0 @@ -package io.quarkus.oidc.deployment.devservices.keycloak; - -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.interfaces.RSAPublicKey; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - -import org.eclipse.microprofile.config.ConfigProvider; -import org.eclipse.microprofile.jwt.Claims; -import org.jboss.logging.Logger; - -import io.quarkus.deployment.IsNormal; -import io.quarkus.deployment.annotations.BuildProducer; -import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; -import io.quarkus.deployment.builditem.DevServicesResultBuildItem; -import io.quarkus.deployment.builditem.DevServicesResultBuildItem.RunningDevService; -import io.quarkus.deployment.builditem.DevServicesSharedNetworkBuildItem; -import io.quarkus.deployment.builditem.LaunchModeBuildItem; -import io.quarkus.deployment.console.ConsoleInstalledBuildItem; -import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; -import io.quarkus.deployment.logging.LoggingSetupBuildItem; -import io.quarkus.oidc.deployment.OidcBuildStep.IsEnabled; -import io.quarkus.oidc.deployment.OidcBuildTimeConfig; -import io.quarkus.oidc.deployment.devservices.OidcDevServicesBuildItem; -import io.quarkus.runtime.configuration.ConfigUtils; -import io.smallrye.jwt.build.Jwt; -import io.vertx.core.http.HttpServerOptions; -import io.vertx.mutiny.core.Vertx; -import io.vertx.mutiny.core.http.HttpServer; -import io.vertx.mutiny.ext.web.Router; -import io.vertx.mutiny.ext.web.RoutingContext; -import io.vertx.mutiny.ext.web.handler.BodyHandler; - -@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = { IsEnabled.class, GlobalDevServicesConfig.Enabled.class }) -public class LightweightDevServicesProcessor { - - private static final Logger LOG = Logger.getLogger(LightweightDevServicesProcessor.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 PROVIDER_CONFIG_KEY = CONFIG_PREFIX + "provider"; - private static final String APPLICATION_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; - private static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; - private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret"; - - private static volatile RunningDevService devService; - static volatile DevServicesConfig capturedDevServicesConfiguration; - private static volatile boolean first = true; - - OidcBuildTimeConfig oidcConfig; - - private static volatile KeyPair kp; - private static volatile String baseURI; - private static volatile String clientId; - - @BuildStep - public DevServicesResultBuildItem startLightweightServer( - List devServicesSharedNetworkBuildItem, - Optional oidcProviderBuildItem, - KeycloakBuildTimeConfig config, - CuratedApplicationShutdownBuildItem closeBuildItem, - LaunchModeBuildItem launchMode, - Optional consoleInstalledBuildItem, - BuildProducer lightweightBuildItemBuildProducer, - LoggingSetupBuildItem loggingSetupBuildItem, - GlobalDevServicesConfig devServicesConfig) { - - if (oidcProviderBuildItem.isPresent()) { - // Dev Services for the alternative OIDC provider are enabled - return null; - } - - if (!config.devservices.lightweight) { - return null; - } - LOG.info("Starting Lightweight OIDC dev services"); - - DevServicesConfig currentDevServicesConfiguration = config.devservices; - // Figure out if we need to shut down and restart any existing Keycloak container - // if not and the Keycloak container has already started we just return - if (devService != null) { - try { - devService.close(); - } catch (Throwable e) { - LOG.error("Failed to stop lightweight container", e); - } - devService = null; - capturedDevServicesConfiguration = null; - } - capturedDevServicesConfiguration = currentDevServicesConfiguration; - try { - List errors = new ArrayList<>(); - - RunningDevService newDevService = startLightweightServer(lightweightBuildItemBuildProducer, - !devServicesSharedNetworkBuildItem.isEmpty(), - devServicesConfig.timeout, - errors); - if (newDevService == null) { - return null; - } - - devService = newDevService; - - if (first) { - first = false; - Runnable closeTask = new Runnable() { - @Override - public void run() { - if (devService != null) { - try { - devService.close(); - } catch (Throwable t) { - LOG.error("Failed to stop Keycloak container", t); - } - } - first = true; - devService = null; - capturedDevServicesConfiguration = null; - } - }; - closeBuildItem.addCloseTask(closeTask, true); - } - } catch (Throwable t) { - throw new RuntimeException(t); - } - LOG.infof("Dev Services for lightweight OIDC started on %s", baseURI); - - return devService.toBuildItem(); - } - - private RunningDevService startLightweightServer( - BuildProducer lightweightBuildItemBuildProducer, - boolean useSharedNetwork, Optional timeout, - List errors) { - if (!capturedDevServicesConfiguration.enabled) { - // explicitly disabled - LOG.debug("Not starting Dev Services for Keycloak as it has been disabled in the config"); - return null; - } - if (!isOidcTenantEnabled()) { - LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.tenant.enabled' is false"); - return null; - } - if (ConfigUtils.isPropertyPresent(AUTH_SERVER_URL_CONFIG_KEY)) { - LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.auth-server-url' has been provided"); - return null; - } - if (ConfigUtils.isPropertyPresent(PROVIDER_CONFIG_KEY)) { - LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.provider' has been provided"); - return null; - } - - Vertx vertx = Vertx.vertx(); - HttpServerOptions options = new HttpServerOptions(); - options.setPort(0); - HttpServer httpServer = vertx.createHttpServer(options); - - Router router = Router.router(vertx); - httpServer.requestHandler(router); - registerRoutes(router); - - httpServer.listenAndAwait(); - int port = httpServer.actualPort(); - - Map configProperties = new HashMap<>(); - baseURI = "http://localhost:" + port; - clientId = getOidcClientId(); - String oidcClientSecret = getOidcClientSecret(); - String oidcApplicationType = getOidcApplicationType(); - configProperties.put(AUTH_SERVER_URL_CONFIG_KEY, baseURI); - configProperties.put(APPLICATION_TYPE_CONFIG_KEY, oidcApplicationType); - configProperties.put(CLIENT_ID_CONFIG_KEY, clientId); - configProperties.put(CLIENT_SECRET_CONFIG_KEY, oidcClientSecret); - - lightweightBuildItemBuildProducer - .produce(new LightweightDevServicesConfigBuildItem(configProperties)); - - return new RunningDevService("oidc-lightweight", null, () -> { - LOG.info("Closing Vertx DEV service for oidc lightweight"); - vertx.closeAndAwait(); - }, configProperties); - } - - private void registerRoutes(Router router) { - BodyHandler bodyHandler = BodyHandler.create(); - router.get("/").handler(this::mainRoute); - router.get("/.well-known/openid-configuration").handler(this::configuration); - router.get("/authorize").handler(this::authorize); - router.post("/login").handler(bodyHandler).handler(this::login); - router.post("/token").handler(bodyHandler).handler(this::accessTokenJson); - router.get("/keys").handler(this::getKeys); - router.get("/logout").handler(this::logout); - - KeyPairGenerator kpg; - try { - kpg = KeyPairGenerator.getInstance("RSA"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - kpg.initialize(2048); - kp = kpg.generateKeyPair(); - } - - private List getUsers() { - if (capturedDevServicesConfiguration.roles.isEmpty()) { - return Arrays.asList("alice", "bob"); - } else { - List ret = new ArrayList<>(capturedDevServicesConfiguration.roles.keySet()); - Collections.sort(ret); - return ret; - } - } - - private List getUserRoles(String user) { - List roles = capturedDevServicesConfiguration.roles.get(user); - return roles == null ? ("alice".equals(user) ? List.of("admin", "user") : List.of("user")) - : roles; - } - - private static boolean isOidcTenantEnabled() { - return ConfigProvider.getConfig().getOptionalValue(TENANT_ENABLED_CONFIG_KEY, Boolean.class).orElse(true); - } - - private static String getOidcApplicationType() { - return ConfigProvider.getConfig().getOptionalValue(APPLICATION_TYPE_CONFIG_KEY, String.class).orElse("service"); - } - - private static String getOidcClientId() { - return ConfigProvider.getConfig().getOptionalValue(CLIENT_ID_CONFIG_KEY, String.class) - .orElse("quarkus-app"); - } - - private static String getOidcClientSecret() { - return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class) - .orElse("the secret must be 32 characters at least to avoid a warning"); - } - - private void mainRoute(RoutingContext rc) { - rc.response().endAndForget("Lightweight OIDC server up and running"); - } - - private void configuration(RoutingContext rc) { - String data = "{\n" - + " \"token_endpoint\":\"" + baseURI + "/token\",\n" - + " \"token_endpoint_auth_methods_supported\":[\n" - + " \"client_secret_post\",\n" - + " \"private_key_jwt\",\n" - + " \"client_secret_basic\"\n" - + " ],\n" - + " \"jwks_uri\":\"" + baseURI + "/keys\",\n" - + " \"response_modes_supported\":[\n" - + " \"query\",\n" - + " \"fragment\",\n" - + " \"form_post\"\n" - + " ],\n" - + " \"subject_types_supported\":[\n" - + " \"pairwise\"\n" - + " ],\n" - + " \"id_token_signing_alg_values_supported\":[\n" - + " \"RS256\"\n" - + " ],\n" - + " \"response_types_supported\":[\n" - + " \"code\",\n" - + " \"id_token\",\n" - + " \"code id_token\",\n" - + " \"id_token token\"\n" - + " ],\n" - + " \"scopes_supported\":[\n" - + " \"openid\",\n" - + " \"profile\",\n" - + " \"email\",\n" - + " \"offline_access\"\n" - + " ],\n" - + " \"issuer\":\"" + baseURI + "/lightweight\",\n" - + " \"request_uri_parameter_supported\":false,\n" - + " \"userinfo_endpoint\":\"" + baseURI + "/userinfo\",\n" - + " \"authorization_endpoint\":\"" + baseURI + "/authorize\",\n" - + " \"device_authorization_endpoint\":\"" + baseURI + "/devicecode\",\n" - + " \"http_logout_supported\":true,\n" - + " \"frontchannel_logout_supported\":true,\n" - + " \"end_session_endpoint\":\"" + baseURI + "/logout\",\n" - + " \"claims_supported\":[\n" - + " \"sub\",\n" - + " \"iss\",\n" - + " \"aud\",\n" - + " \"exp\",\n" - + " \"iat\",\n" - + " \"auth_time\",\n" - + " \"acr\",\n" - + " \"nonce\",\n" - + " \"preferred_username\",\n" - + " \"name\",\n" - + " \"tid\",\n" - + " \"ver\",\n" - + " \"at_hash\",\n" - + " \"c_hash\",\n" - + " \"email\"\n" - + " ]\n" - + "}"; - rc.response().putHeader("Content-Type", "application/json"); - rc.endAndForget(data); - } - - /* - * First request: - * GET - * https://localhost:X/authorize?response_type=code&client_id=SECRET&scope=openid+openid+ - * email+profile&redirect_uri=http://localhost:8080/Login/oidcLoginSuccess&state=STATE - * - * returns a 302 to - * GET http://localhost:8080/Login/oidcLoginSuccess?code=CODE&state=STATE - */ - private void authorize(RoutingContext rc) { - String response_type = rc.request().params().get("response_type"); - String clientId = rc.request().params().get("client_id"); - String scope = rc.request().params().get("scope"); - String state = rc.request().params().get("state"); - String redirect_uri = rc.request().params().get("redirect_uri"); - UUID code = UUID.randomUUID(); - URI redirect; - try { - redirect = new URI(redirect_uri + "?state=" + state); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - StringBuilder predefinedUsers = new StringBuilder(); - for (String predefinedUser : getUsers()) { - predefinedUsers.append(" \n"); - } - rc.response() - .endAndForget("" - + " " - + " Login" - + " \n" - + " \n" - + " \n" - + "
\n" - + "
\n" - + "
Login
\n" - + "
\n" - + "
\n" - + "
\n" - + " \n" - + predefinedUsers - + "
\n" - + "
\n" - + " Custom user\n" - + "
\n" - + " " - + "
" - + "
" - + " \n" - + "
\n" - + "
\n" - + "
\n" - + "
\n" - + " \n" - + ""); - } - - private void login(RoutingContext rc) { - String redirect_uri = rc.request().params().get("redirect_uri"); - String predefined = rc.request().params().get("predefined"); - String name = rc.request().params().get("name"); - String roles = rc.request().params().get("roles"); - if (predefined != null) { - name = predefined; - roles = String.join(",", getUserRoles(name)); - } - if (name == null || name.isBlank()) { - name = "user"; - } - // store user|roles in the code param as Base64 - String code = Base64.getUrlEncoder().encodeToString((name + "|" + roles).getBytes(StandardCharsets.UTF_8)); - rc.response() - .putHeader("Location", redirect_uri + "&code=" + code) - .setStatusCode(302) - .endAndForget(); - } - - /* - * OIDC calls POST /token - * grant_type=authorization_code - * &code=CODE - * &redirect_uri=URI - * - * returns: - * - * { - * "token_type":"Bearer", - * "scope":"openid email profile", - * "expires_in":3600, - * "ext_expires_in":3600, - * "access_token":TOKEN, - * "id_token":JWT - * } - * - * ID token: - * { - * "ver": "2.0", - * "iss": "http://localhost/lightweight", - * "sub": "USERID", - * "aud": "CLIENTID", - * "exp": 1641906214, - * "iat": 1641819514, - * "nbf": 1641819514, - * "name": "Foo Bar", - * "preferred_username": "user@example.com", - * "oid": "OPAQUE", - * "email": "user@example.com", - * "tid": "TENANTID", - * "aio": "AZURE_OPAQUE" - * } - */ - private void accessTokenJson(RoutingContext rc) { - String authorization_code = rc.request().formAttributes().get("authorization_code"); - String code = rc.request().formAttributes().get("code"); - String redirect_uri = rc.request().formAttributes().get("redirect_uri"); - String decodedCode = new String(Base64.getUrlDecoder().decode(code), StandardCharsets.UTF_8); - int separator = decodedCode.indexOf('|'); - String user = decodedCode.substring(0, separator); - String rolesAsString = decodedCode.substring(separator + 1); - Set roles = new HashSet<>(Arrays.asList(rolesAsString.split("[,\\s]+"))); - - String accessToken = Jwt.claims() - .expiresIn(Duration.ofDays(1)) - .issuedAt(Instant.now()) - .issuer(baseURI + "/lightweight") - .subject(user) - .upn(user) - // not sure if the next three are even used - .claim("name", "Foo Bar") - .claim(Claims.preferred_username, user + "@example.com") - .claim(Claims.email, user + "@example.com") - .groups(roles) - .jws() - .keyId("KEYID") - .sign(kp.getPrivate()); - String idToken = Jwt.claims() - .expiresIn(Duration.ofDays(1)) - .issuedAt(Instant.now()) - .issuer(baseURI + "/lightweight") - .audience(clientId) - .subject(user) - .upn(user) - .claim("name", "Foo Bar") - .claim(Claims.preferred_username, user + "@example.com") - .claim(Claims.email, user + "@example.com") - .groups(roles) - .jws() - .keyId("KEYID") - .sign(kp.getPrivate()); - - String data = "{\n" - + " \"token_type\":\"Bearer\",\n" - + " \"scope\":\"openid email profile\",\n" - + " \"expires_in\":3600,\n" - + " \"ext_expires_in\":3600,\n" - + " \"access_token\":\"" + accessToken + "\",\n" - + " \"id_token\":\"" + idToken + "\"\n" - + " } "; - rc.response() - .putHeader("Content-Type", "application/json") - .endAndForget(data); - } - - /* - * {"kty":"RSA", - * "use":"sig", - * "kid":"KEYID", - * "x5t":"KEYID", - * "n": - * "", - * "e":"", - * "x5c":[ - * "KEYID" - * ], - * "issuer":"http://localhost/lightweight"}, - */ - private void getKeys(RoutingContext rc) { - RSAPublicKey pub = (RSAPublicKey) kp.getPublic(); - String modulus = Base64.getUrlEncoder().encodeToString(pub.getModulus().toByteArray()); - String exponent = Base64.getUrlEncoder().encodeToString(pub.getPublicExponent().toByteArray()); - String data = "{\n" - + " \"keys\": [\n" - + " {\n" - + " \"alg\": \"RS256\",\n" - + " \"kty\": \"RSA\",\n" - + " \"n\": \"" + modulus + "\",\n" - + " \"use\": \"sig\",\n" - + " \"kid\": \"KEYID\",\n" - + " \"k5t\": \"KEYID\",\n" - + " \"issuer\": \"" + baseURI + "/lightweight\",\n" - + " \"e\": \"" + exponent + "\"\n" - + " },\n" - + " ]\n" - + "}"; - rc.response() - .putHeader("Content-Type", "application/json") - .endAndForget(data); - } - - /* - * /logout - * ?post_logout_redirect_uri=URI - * &id_token_hint=SECRET - */ - private void logout(RoutingContext rc) { - // we have no cookie state - String redirect_uri = rc.request().params().get("post_logout_redirect_uri"); - rc.response() - .putHeader("Location", redirect_uri) - .setStatusCode(302) - .endAndForget(); - } -} diff --git a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js index 2b1e92e9f9539..844fb4d269a2c 100644 --- a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js +++ b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js @@ -1,8 +1,8 @@ -import { QwcHotReloadElement, html, css} from 'qwc-hot-reload-element'; +import {css, html, QwcHotReloadElement} from 'qwc-hot-reload-element'; import {classMap} from 'lit/directives/class-map.js'; import {unsafeHTML} from 'lit/directives/unsafe-html.js'; import {JsonRpc} from 'jsonrpc'; -import { LitState } from 'lit-element-state'; +import {LitState} from 'lit-element-state'; import '@vaadin/button'; import '@vaadin/details'; import '@vaadin/horizontal-layout'; @@ -10,11 +10,9 @@ import '@vaadin/icon'; import '@vaadin/message-list'; import '@vaadin/password-field'; import '@vaadin/split-layout'; -import { notifier } from 'notifier'; -import { Router } from '@vaadin/router'; -import { - devRoot -} from 'build-time-data'; +import {notifier} from 'notifier'; +import {Router} from '@vaadin/router'; +import {devRoot} from 'build-time-data'; /** * This keeps state of OIDC properties that can potentially change on hot reload. @@ -44,12 +42,14 @@ class OidcPropertiesState extends LitState { postLogoutUriParam: null, scopes: null, authExtraParams: null, - httpPort: 0, + httpPort: 8080, accessToken: null, idToken: null, userName: null, propertiesStateId: null, - testServiceResponses: null + testServiceResponses: null, + webAppLoginObserver: null, + isWebApp: false }; } @@ -79,6 +79,7 @@ class OidcPropertiesState extends LitState { propertiesState.httpPort = response.result.httpPort; propertiesState.oidcProviderName = response.result.oidcProviderName; propertiesState.oidcApplicationType = response.result.oidcApplicationType; + propertiesState.isWebApp = propertiesState?.oidcApplicationType === 'web-app' propertiesState.oidcGrantType = response.result.oidcGrantType; propertiesState.swaggerIsAvailable = response.result.swaggerIsAvailable; propertiesState.graphqlIsAvailable = response.result.graphqlIsAvailable; @@ -232,9 +233,11 @@ export class QwcOidcProvider extends QwcHotReloadElement { .half-width { width: 50%; } - .jwt-tooltip { - cursor: help; - background: rgba(0, 0, 0, .1); + .jwt-tooltip-bg { + background: rgba(0, 0, 0, .1); + } + .jwt-tooltip-cursor { + cursor: url("data:image/svg+xml,%3Csvg height='0.8rem' width='0.8rem' fill='%23000000' viewBox='0 0 318.293 318.293' xml:space='preserve' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cg%3E%3Cpath d='M 159.148,0 C 106.452,0 63.604,39.326 63.604,87.662 h 47.736 c 0,-22.007 21.438,-39.927 47.808,-39.927 26.367,0 47.804,17.92 47.804,39.927 v 6.929 c 0,23.39 -10.292,34.31 -25.915,50.813 -20.371,21.531 -45.744,48.365 -45.744,105.899 h 47.745 c 0,-38.524 15.144,-54.568 32.692,-73.12 17.368,-18.347 38.96,-41.192 38.96,-83.592 V 87.662 C 254.689,39.326 211.845,0 159.148,0 Z' style='fill:%234087d4;fill-opacity:1' /%3E%3Crect x='134.475' y='277.996' width='49.968' height='40.297' style='fill:%234087d4;fill-opacity:1' /%3E%3C/g%3E%3C/svg%3E"), help; } `; @@ -313,6 +316,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { } hotReload(){ + QwcOidcProvider._cancelWebAppLoginObserver(); propertiesState.propertiesStateId = null; OidcPropertiesState.clearTestServiceResponses(); QwcOidcProvider._loadProperties(this.jsonRpc).then(result => { @@ -755,7 +759,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { Test your service - QwcOidcProvider._navigateToSwaggerUi()} ?hidden="${!propertiesState.swaggerIsAvailable}"> @@ -774,14 +778,21 @@ export class QwcOidcProvider extends QwcHotReloadElement { ${servicePathForm} - this._testServiceWithAccessToken()}> With Access Token - this._testServiceWithIdToken()}> With ID Token + this._signInToService()}> + Test + ${testServiceResultsHtml} @@ -812,7 +823,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { static _testServiceResultsHtml() { return html` - + { - // logged in + // logged out + + propertiesState.hideImplicitLoggedIn = true; + propertiesState.userName = null; + + if (QwcOidcProvider._isErrorInUrl()) { propertiesState.hideImplLoggedOut = true; + propertiesState.hideLogInErr = false; + } else { propertiesState.hideLogInErr = true; - propertiesState.hideImplicitLoggedIn = false; - onUpdateDone(); - }, () => { - // logged out - propertiesState.hideImplicitLoggedIn = true; - propertiesState.userName = null; - - if (QwcOidcProvider._isErrorInUrl()) { - propertiesState.hideImplLoggedOut = true; - propertiesState.hideLogInErr = false; - } else { - propertiesState.hideLogInErr = true; - propertiesState.hideImplLoggedOut = false; - } - - propertiesState.accessToken = null; - propertiesState.idToken = null; - onUpdateDone(); - }); + propertiesState.hideImplLoggedOut = false; + } + + propertiesState.accessToken = null; + propertiesState.idToken = null; + onUpdateDone(); } } @@ -1122,9 +1128,57 @@ export class QwcOidcProvider extends QwcHotReloadElement { } } + static _cancelWebAppLoginObserver() { + if (propertiesState.webAppLoginObserver !== null) { + propertiesState.webAppLoginObserver.cancel(); + propertiesState.webAppLoginObserver = null; + } + } + + static _checkSessionCookieAndUpdateState(jsonRpc, onUpdateDone) { + QwcOidcProvider._checkSessionCookie(jsonRpc, () => { + // logged in + propertiesState.hideImplLoggedOut = true; + propertiesState.hideLogInErr = true; + propertiesState.hideImplicitLoggedIn = false; + + QwcOidcProvider._cancelWebAppLoginObserver(); + + onUpdateDone(); + }, () => { + // logged out + propertiesState.hideImplicitLoggedIn = true; + propertiesState.userName = null; + + if (QwcOidcProvider._isErrorInUrl()) { + propertiesState.hideImplLoggedOut = true; + propertiesState.hideLogInErr = false; + } else { + propertiesState.hideLogInErr = true; + propertiesState.hideImplLoggedOut = false; + } + + propertiesState.accessToken = null; + propertiesState.idToken = null; + + if (propertiesState.webAppLoginObserver === null) { + propertiesState.webAppLoginObserver = jsonRpc.streamOidcLoginEvent().onNext(jsonRpcResponse => { + const isLoggedIn = jsonRpcResponse?.result; + if (isLoggedIn) { + QwcOidcProvider._cancelWebAppLoginObserver(); + QwcOidcProvider._checkSessionCookieAndUpdateState(jsonRpc, onUpdateDone); + } + }); + } + + onUpdateDone(); + }); + } + static _checkSessionCookie(jsonRpc, onLoggedIn, onLoggedOut) { - // FIXME: port, path? - fetch("http://localhost:8080/q/io.quarkus.quarkus-oidc/readSessionCookie") + // FIXME: hardcoded path? + const port = propertiesState.httpPort ?? 8080 + fetch("http://localhost:" + port + "/q/io.quarkus.quarkus-oidc/readSessionCookie") .then(response => response.json()) .then(result => { if ("id_token" in result || "access_token" in result) { @@ -1194,7 +1248,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { const clientId = this._getClientId(); let address; - if (propertiesState.keycloakAdminUrl && this._selectedRealm) { + if (!propertiesState.isWebApp && propertiesState.keycloakAdminUrl && this._selectedRealm) { address = propertiesState.keycloakAdminUrl + '/realms/' + this._selectedRealm + '/protocol/openid-connect/logout'; } else { address = propertiesState.logoutUrl; @@ -1215,8 +1269,8 @@ export class QwcOidcProvider extends QwcHotReloadElement { const signature = parts[2]?.trim(); return html` - ${headers}.${payload}.${signature} + ${headers}.${payload}.${signature} `; } else if (parts.length === 5) { const headers = parts[0]?.trim(); @@ -1226,10 +1280,10 @@ export class QwcOidcProvider extends QwcHotReloadElement { const authTag = parts[4]?.trim(); return html` - ${headers}.${encryptedKey}.${initVector}.${ciphertext}.${authTag} + ${headers}.${encryptedKey}.${initVector}.${ciphertext}.${authTag} `; } else { return html`${token}`; @@ -1251,10 +1305,22 @@ export class QwcOidcProvider extends QwcHotReloadElement { "iss": "Issuer", "sub": "Subject", "aud": "Audience", - "nbf": "Not Before", - "iat": "Issued At", "exp": "Expiration Time", - "jti": "JWT ID" + "iat": "Issued At", + "auth_time": "End-User Authentication Time", + "nonce": "Cryptographic Nonce", + "acr": "Authentication Context Class Reference", + "amr": "Authentication Methods References", + "azp": "Authorized Party", + "nbf": "Not Before", + "jti": "JWT ID", + "sid": "Session ID", + "scope": "Scope", + "upn": "User Principal Name", + "groups": "Groups", + "kid": "Key ID", + "alg": "Algorithm", + "typ": "Token Type" }; const spaces = 4; var ret = "{"; @@ -1269,7 +1335,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { // decorate key var tooltip = tooltips[k]; if(tooltip) { - ret += "\"" + k + "\""; + ret += "\"" + k + "\""; } else { ret += "\"" + k + "\""; } @@ -1277,7 +1343,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { ret += ": "; // decorate values if(k == 'iat' || k == 'nbf' || k == 'exp'){ - ret += "" + val + ""; + ret += "" + val + ""; } else { ret += JSON.stringify(val); } @@ -1297,14 +1363,16 @@ export class QwcOidcProvider extends QwcHotReloadElement { const parts = token.split("."); if (parts.length === 3) { const headers = QwcOidcProvider._decodeBase64(parts[0]); + const headersJsonObj = JSON.parse(headers); + const headersHtml = QwcOidcProvider._formatJson(headersJsonObj); const payload = QwcOidcProvider._decodeBase64(parts[1]); const signature = parts[2]; const jsonPayload = JSON.parse(payload); const json = QwcOidcProvider._formatJson(jsonPayload); return html` -
${JSON.stringify(JSON.parse(headers), null, 4)?.trim()}
-
${unsafeHTML(json?.trim())}
- ${signature?.trim()} +
${unsafeHTML(headersHtml?.trim())}
+
${unsafeHTML(json?.trim())}
+ ${signature?.trim()} `; } else if (parts.length === 5) { const headers = window.atob(parts[0]?.trim()); @@ -1314,11 +1382,11 @@ export class QwcOidcProvider extends QwcHotReloadElement { const authTag = parts[4]?.trim(); return html` -
${JSON.stringify(JSON.parse(headers), null, 4)?.trim()}
-
${encryptedKey}
-
${initVector}
-
${ciphertext}
- ${authTag} +
${JSON.stringify(JSON.parse(headers), null, 4)?.trim()}
+
${encryptedKey}
+
${initVector}
+
${ciphertext}
+ ${authTag} `; } else { return html`${token}`; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java index 2ab60d98f32fd..2944e03ffa7ae 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java @@ -4,11 +4,13 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; import org.eclipse.microprofile.config.ConfigProvider; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.smallrye.common.annotation.NonBlocking; +import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import io.vertx.core.Vertx; @@ -16,6 +18,9 @@ public class OidcDevJsonRpcService { private OidcDevUiRpcSvcPropertiesBean props; private HttpConfiguration httpConfiguration; + @Inject + OidcDevLoginObserver oidcDevTokensObserver; + private Vertx vertx; @PostConstruct @@ -63,6 +68,10 @@ public Uni testServiceWithClientCred(String tokenUrl, String serviceUrl, props.getWebClientTimeout(), props.getClientCredGrantOptions()); } + public Multi streamOidcLoginEvent() { + return oidcDevTokensObserver.streamOidcLoginEvent(); + } + public void hydrate(OidcDevUiRpcSvcPropertiesBean properties, HttpConfiguration httpConfiguration) { this.props = properties; this.httpConfiguration = httpConfiguration; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java new file mode 100644 index 0000000000000..cc5ab5b683d6b --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java @@ -0,0 +1,70 @@ +package io.quarkus.oidc.runtime.devui; + +import java.time.Duration; +import java.util.function.Function; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import io.quarkus.oidc.SecurityEvent; +import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.operators.multi.processors.BroadcastProcessor; +import io.vertx.core.AsyncResult; +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class OidcDevLoginObserver { + + private final BroadcastProcessor oidcLoginStream; + + OidcDevLoginObserver(OidcConfig config) { + boolean isWebApplication = ApplicationType.WEB_APP == OidcConfig.getDefaultTenant(config).applicationType() + .orElse(null); + if (isWebApplication) { + this.oidcLoginStream = BroadcastProcessor.create(); + } else { + this.oidcLoginStream = null; + } + } + + void observeOidcLogin(@Observes SecurityEvent event) { + if (oidcLoginStream != null && event.getEventType() == SecurityEvent.Type.OIDC_LOGIN) { + RoutingContext routingContext = event.getSecurityIdentity().getAttribute(RoutingContext.class.getName()); + if (routingContext != null && !routingContext.response().ended()) { + routingContext.addEndHandler(new Handler>() { + @Override + public void handle(AsyncResult voidAsyncResult) { + oidcLoginStream.onNext(true); + } + }); + } else { + oidcLoginStream.onNext(true); + } + } + } + + Multi streamOidcLoginEvent() { + return oidcLoginStream == null ? Multi.createFrom().empty() : oidcLoginStream.onItem().call(delayByOneSecond()); + } + + private static Function> delayByOneSecond() { + return new Function>() { + @Override + public Uni apply(Boolean i) { + if (Boolean.TRUE.equals(i)) { + // we inform about login once response has ended, + // but we need to wait a bit till response is sent and cookies present on the browser side + // if this proves unreliable, we can add retry on the front end side instead of the delay + return Uni.createFrom().item(true).onItem().delayIt().by(Duration.ofSeconds(1)); + } else { + return Uni.createFrom().nothing(); + } + } + }; + } + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java index 32401f5bb49f2..cba0d21d1de04 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java @@ -1,11 +1,8 @@ package io.quarkus.oidc.runtime.devui; -import java.util.regex.Pattern; - -import org.jboss.logging.Logger; - import io.quarkus.arc.Arc; import io.quarkus.oidc.AuthorizationCodeTokens; +import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.runtime.DefaultTokenStateManager; import io.quarkus.oidc.runtime.OidcConfig; import io.quarkus.oidc.runtime.OidcUtils; @@ -14,19 +11,21 @@ import io.vertx.core.http.Cookie; import io.vertx.ext.web.RoutingContext; -public class OidcDevSessionCookieReaderHandler implements Handler { - private static final Logger LOG = Logger.getLogger(OidcDevSessionCookieReaderHandler.class); - static final String COOKIE_DELIM = "|"; - static final Pattern COOKIE_PATTERN = Pattern.compile("\\" + COOKIE_DELIM); +final class OidcDevSessionCookieReaderHandler implements Handler { + + private final OidcTenantConfig defaultTenantConfig; + + OidcDevSessionCookieReaderHandler(OidcConfig oidcConfig) { + this.defaultTenantConfig = OidcTenantConfig.of(OidcConfig.getDefaultTenant(oidcConfig)); + } @Override public void handle(RoutingContext rc) { Cookie cookie = rc.request().getCookie(OidcUtils.SESSION_COOKIE_NAME); if (cookie != null) { DefaultTokenStateManager tokenStateManager = Arc.container().instance(DefaultTokenStateManager.class).get(); - OidcConfig oidcConfig = Arc.container().instance(OidcConfig.class).get(); - Uni tokensUni = tokenStateManager.getTokens(rc, oidcConfig.defaultTenant, - cookie.getValue(), null); + Uni tokensUni = tokenStateManager.getTokens(rc, defaultTenantConfig, cookie.getValue(), + null); tokensUni.subscribe().with(tokens -> { rc.response().setStatusCode(200); rc.response().putHeader("Content-Type", "application/json"); @@ -34,7 +33,7 @@ public void handle(RoutingContext rc) { + "\", \"refresh_token\": \"" + tokens.getRefreshToken() + "\"}"); - }); + }, rc::fail); } else { rc.response().setStatusCode(200); rc.response().putHeader("Content-Type", "application/json"); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java index d6ae3cb7c8cb5..49d924add3836 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java @@ -1,14 +1,11 @@ package io.quarkus.oidc.runtime.devui; -import org.jboss.logging.Logger; - import io.quarkus.oidc.runtime.OidcUtils; import io.vertx.core.Handler; import io.vertx.core.http.impl.ServerCookie; import io.vertx.ext.web.RoutingContext; -public class OidcDevSessionLogoutHandler implements Handler { - private static final Logger LOG = Logger.getLogger(OidcDevSessionLogoutHandler.class); +final class OidcDevSessionLogoutHandler implements Handler { @Override public void handle(RoutingContext rc) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java index 5c31936dbdfb1..f1b220e4d1610 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java @@ -5,6 +5,7 @@ import java.util.Map; import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.oidc.runtime.OidcConfig; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.vertx.http.runtime.HttpConfiguration; @@ -14,6 +15,12 @@ @Recorder public class OidcDevUiRecorder { + private final RuntimeValue oidcConfigRuntimeValue; + + public OidcDevUiRecorder(RuntimeValue oidcConfigRuntimeValue) { + this.oidcConfigRuntimeValue = oidcConfigRuntimeValue; + } + public void createJsonRPCService(BeanContainer beanContainer, RuntimeValue oidcDevUiRpcSvcPropertiesBean, HttpConfiguration httpConfiguration) { OidcDevJsonRpcService jsonRpcService = beanContainer.beanInstance(OidcDevJsonRpcService.class); @@ -34,10 +41,11 @@ public RuntimeValue getRpcServiceProperties(Strin } public Handler readSessionCookieHandler() { - return new OidcDevSessionCookieReaderHandler(); + return new OidcDevSessionCookieReaderHandler(oidcConfigRuntimeValue.getValue()); } public Handler logoutHandler() { return new OidcDevSessionLogoutHandler(); } + } diff --git a/integration-tests/oidc-dev-services/pom.xml b/integration-tests/oidc-dev-services/pom.xml new file mode 100644 index 0000000000000..eace1af7f7741 --- /dev/null +++ b/integration-tests/oidc-dev-services/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-oidc-dev-services + Quarkus - Integration Tests - Dev Services for OIDC + Dev Services for OIDC integration tests module + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.htmlunit + htmlunit + test + + + org.eclipse.jetty + * + + + + + + io.quarkus + quarkus-rest-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-oidc-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + maven-surefire-plugin + + + maven-failsafe-plugin + + + io.quarkus + quarkus-maven-plugin + + + + generate-code + build + + + + + + + + diff --git a/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java b/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java new file mode 100644 index 0000000000000..dcc3a947e72a9 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java @@ -0,0 +1,59 @@ +package io.quarkus.it.oidc.dev.services; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.IdToken; +import io.quarkus.oidc.UserInfo; +import io.quarkus.security.identity.SecurityIdentity; + +@Path("secured") +public class SecuredResource { + + @Inject + SecurityIdentity securityIdentity; + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + UserInfo userInfo; + + @Inject + @ConfigProperty(name = "quarkus.oidc.application-type", defaultValue = "service") + String applicationType; + + @Inject + @ConfigProperty(name = "quarkus.oidc.auth-server-url") + String serverUrl; + + @RolesAllowed("admin") + @GET + @Path("admin-only") + public String getAdminOnly() { + return (isWebApp() ? idToken.getName() : securityIdentity.getPrincipal().getName()) + " " + securityIdentity.getRoles(); + } + + @RolesAllowed("user") + @GET + @Path("user-only") + public String getUserOnly() { + return userInfo.getPreferredUserName() + " " + securityIdentity.getRoles(); + } + + @GET + @Path("auth-server-url") + public String getAuthServerUrl() { + return serverUrl; + } + + private boolean isWebApp() { + return "web-app".equals(applicationType); + } +} diff --git a/integration-tests/oidc-dev-services/src/main/resources/application.properties b/integration-tests/oidc-dev-services/src/main/resources/application.properties new file mode 100644 index 0000000000000..636d87caec1ef --- /dev/null +++ b/integration-tests/oidc-dev-services/src/main/resources/application.properties @@ -0,0 +1,3 @@ +quarkus.oidc.devservices.enabled=true + +%code-flow.quarkus.oidc.application-type=web-app diff --git a/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java new file mode 100644 index 0000000000000..2bf25252718b7 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java @@ -0,0 +1,8 @@ +package io.quarkus.it.oidc.dev.services; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class BearerAuthenticationOidcDevServicesIT extends BearerAuthenticationOidcDevServicesTest { + +} diff --git a/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java new file mode 100644 index 0000000000000..eac0592af5e07 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java @@ -0,0 +1,77 @@ +package io.quarkus.it.oidc.dev.services; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +public class BearerAuthenticationOidcDevServicesTest { + + @Test + public void testLoginAsCustomUser() { + RestAssured.given() + .auth().oauth2(getAccessToken("Ronald", "admin")) + .get("/secured/admin-only") + .then() + .statusCode(200) + .body(Matchers.containsString("Ronald")) + .body(Matchers.containsString("admin")); + RestAssured.given() + .auth().oauth2(getAccessToken("Ronald", "admin")) + .get("/secured/user-only") + .then() + .statusCode(403); + } + + @Test + public void testLoginAsAlice() { + RestAssured.given() + .auth().oauth2(getAccessToken("alice")) + .get("/secured/admin-only") + .then() + .statusCode(200) + .body(Matchers.containsString("alice")) + .body(Matchers.containsString("admin")) + .body(Matchers.containsString("user")); + RestAssured.given() + .auth().oauth2(getAccessToken("alice")) + .get("/secured/user-only") + .then() + .statusCode(200) + .body(Matchers.containsString("alice")) + .body(Matchers.containsString("admin")) + .body(Matchers.containsString("user")); + } + + @Test + public void testLoginAsBob() { + RestAssured.given() + .auth().oauth2(getAccessToken("bob")) + .get("/secured/admin-only") + .then() + .statusCode(403); + RestAssured.given() + .auth().oauth2(getAccessToken("bob")) + .get("/secured/user-only") + .then() + .statusCode(200) + .body(Matchers.containsString("bob")) + .body(Matchers.containsString("user")); + } + + private String getAccessToken(String user) { + return RestAssured.given().get(getAuthServerUrl() + "/testing/generate/access-token?user=" + user).asString(); + } + + private String getAccessToken(String user, String... roles) { + return RestAssured.given() + .get(getAuthServerUrl() + "/testing/generate/access-token?user=" + user + "&roles=" + String.join(",", roles)) + .asString(); + } + + private static String getAuthServerUrl() { + return RestAssured.get("/secured/auth-server-url").then().statusCode(200).extract().body().asString(); + } +} diff --git a/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java new file mode 100644 index 0000000000000..1ff5e97a19666 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java @@ -0,0 +1,127 @@ +package io.quarkus.it.oidc.dev.services; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.net.URI; + +import org.htmlunit.FailingHttpStatusCodeException; +import org.htmlunit.SilentCssErrorHandler; +import org.htmlunit.TextPage; +import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.html.HtmlForm; +import org.htmlunit.html.HtmlPage; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; + +@QuarkusTest +@TestProfile(CodeFlowOidcDevServicesTest.OidcWebAppTestProfile.class) +public class CodeFlowOidcDevServicesTest { + + @Test + public void testLoginAsCustomUser() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/secured/admin-only"); + + Assertions.assertEquals("Login", page.getTitleText()); + + HtmlForm loginForm = page.getForms().stream().filter(form -> "custom-form".equals(form.getAttribute("class"))) + .findFirst().get(); + + loginForm.getInputByName("name").setValueAttribute("Ronald"); + loginForm.getInputByName("roles").setValueAttribute("admin,user"); + + TextPage adminOnlyPage = loginForm.getButtonByName("login").click(); + Assertions.assertTrue(adminOnlyPage.getContent().contains("Ronald")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("admin")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("user")); + + assertNotNull(webClient.getCookieManager().getCookie("q_session")); + + testLogout(webClient); + } + } + + @Test + public void testLoginAsAlice() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/secured/admin-only"); + + Assertions.assertEquals("Login", page.getTitleText()); + + HtmlForm loginForm = page.getForms().stream().filter(form -> "predefined-form".equals(form.getAttribute("class"))) + .findFirst().get(); + + TextPage adminOnlyPage = loginForm.getButtonByName("predefined-alice").click(); + Assertions.assertTrue(adminOnlyPage.getContent().contains("alice")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("admin")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("user")); + + testLogout(webClient); + } + } + + @Test + public void testLoginAsBob() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/secured/user-only"); + + Assertions.assertEquals("Login", page.getTitleText()); + + HtmlForm loginForm = page.getForms().stream().filter(form -> "predefined-form".equals(form.getAttribute("class"))) + .findFirst().get(); + + TextPage adminOnlyPage = loginForm.getButtonByName("predefined-bob").click(); + Assertions.assertTrue(adminOnlyPage.getContent().contains("bob")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("user")); + Assertions.assertFalse(adminOnlyPage.getContent().contains("admin")); + + try { + webClient.getPage("http://localhost:8081/secured/admin-only"); + fail("Exception is expected because Bob is not admin"); + } catch (FailingHttpStatusCodeException ex) { + Assertions.assertEquals(403, ex.getStatusCode()); + } + + testLogout(webClient); + } + } + + private static void testLogout(WebClient webClient) throws IOException { + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse(new WebRequest(URI.create(getAuthServerUrl() + + "/logout?post_logout_redirect_uri=north-pole&id_token_hint=SECRET") + .toURL())); + Assertions.assertEquals(302, webResponse.getStatusCode()); + Assertions.assertEquals("north-pole", webResponse.getResponseHeaderValue("Location")); + + webClient.getCookieManager().clearCookies(); + } + + private static WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } + + private static String getAuthServerUrl() { + return RestAssured.get("/secured/auth-server-url").then().statusCode(200).extract().body().asString(); + } + + public static class OidcWebAppTestProfile implements QuarkusTestProfile { + @Override + public String getConfigProfile() { + return "code-flow"; + } + } + +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 6376d450f75fa..55f67dc439690 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -257,6 +257,7 @@ oidc-client-registration oidc-client-reactive oidc-client-wiremock + oidc-dev-services oidc-mtls oidc-token-propagation oidc-token-propagation-reactive