From a3ccaa32e6c31fe3737db1d589f3b2a3865d901b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Wed, 25 Dec 2024 15:39:22 +0100 Subject: [PATCH] Finalize Dev Services for OIDC --- .github/native-tests.json | 2 +- bom/application/pom.xml | 5 + .../security-openid-connect-dev-services.adoc | 14 + .../keycloak/KeycloakDevServicesConfig.java | 6 - .../KeycloakDevServicesProcessor.java | 7 +- extensions/devservices/oidc/pom.xml | 53 ++ .../oidc/OidcDevServicesConfig.java | 35 + .../oidc/OidcDevServicesConfigBuildItem.java} | 10 +- .../oidc/OidcDevServicesProcessor.java | 873 ++++++++++++++++++ extensions/devservices/pom.xml | 1 + extensions/oidc/deployment/pom.xml | 4 + .../devservices/OidcDevUIProcessor.java | 10 +- .../keycloak/KeycloakDevUIProcessor.java | 4 +- .../LightweightDevServicesProcessor.java | 628 ------------- .../resources/dev-ui/qwc-oidc-provider.js | 26 +- .../OidcDevSessionCookieReaderHandler.java | 23 +- .../devui/OidcDevSessionLogoutHandler.java | 5 +- .../oidc/runtime/devui/OidcDevUiRecorder.java | 9 +- 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 + 25 files changed, 1422 insertions(+), 667 deletions(-) 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 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 d85cf5c0e4fe5..9de770965cf49 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/security-openid-connect-dev-services.adoc b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc index dcdbdbeac0415..6b9c5376cfb0b 100644 --- a/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc @@ -26,6 +26,7 @@ Additionally, xref:dev-ui.adoc[Dev UI] available at http://localhost:8080/q/dev[ 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: @@ -406,6 +407,19 @@ 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 +---- + +Once enabled, Quarkus starts a new server that supports most common OpenID Connect operations. +Note, the Dev Services for OIDC are enabled by default if Docker and Podman are not available. + == 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 6227b263b3125..8709a2a76b2c2 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..cee8f27bc0899 --- /dev/null +++ b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java @@ -0,0 +1,873 @@ +package io.quarkus.devservices.oidc; + +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.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 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 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 closeDevService; + + @BuildStep + public DevServicesResultBuildItem startServer(CuratedApplicationShutdownBuildItem closeBuildItem, + OidcDevServicesConfig devServicesConfig, DockerStatusBuildItem dockerStatusBuildItem, + BuildProducer devServiceConfigProducer) { + if (shouldNotStartServer(devServicesConfig, dockerStatusBuildItem)) { + if (closeDevService != null) { + closeDevService.run(); + closeDevService = null; + } + return null; + } + + userToDefaultRoles = devServicesConfig.roles(); + if (closeDevService == 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(); + closeDevService = 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(() -> { + if (closeDevService != null) { + closeDevService.run(); + closeDevService = null; + } + }, true); + updateDevSvcConfigProperties(); + LOG.infof("Dev Services for OIDC started on %s", baseURI); + } + + RunningDevService newDevService = createRunningDevService(devServiceConfigProducer); + return newDevService.toBuildItem(); + } + + 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 RunningDevService createRunningDevService( + BuildProducer devServiceConfigProducer) { + if (!getOidcClientId().equals(clientId) || !getOidcApplicationType().equals(applicationType)) { + updateDevSvcConfigProperties(); + } + devServiceConfigProducer.produce(new OidcDevServicesConfigBuildItem(configProperties)); + return new RunningDevService("oidc-dev-services", null, () -> { + }, configProperties); + } + + 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(); + } + + 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); + default -> rc.response() + .setStatusCode(400) + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget("Unsupported grant type: " + grantType); + } + } + + 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 (!OidcDevServicesProcessor.clientId.equals(clientId)) { + LOG.warn("Client ID does not match, denying token refresh"); + invalidTokenResponse(rc); + return; + } + if (!OidcDevServicesProcessor.clientSecret.equals(clientSecret)) { + LOG.warn("Client secret does not match, 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", "Foo Bar") + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId("KEYID") + .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", "Foo Bar") + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId("KEYID") + .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": "KEYID", + "k5t": "KEYID", + "issuer": "%s", + "e": "%s" + }, + ] + } + """.formatted(modulus, 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": "%s", + "sub": "%s", + "name": "Foo Bar", + "family_name": "Foo", + "given_name": "Bar", + "email": "%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]+"))); + } + } + + } + +} 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/OidcDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java index def279cb26ad3..1c5f11b5cd7c6 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,6 +17,7 @@ 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; @@ -24,7 +25,6 @@ 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; @@ -69,13 +69,13 @@ 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 String authServerUrl = oidcDevServicesConfigBuildItem.isPresent() + ? oidcDevServicesConfigBuildItem.get().getConfig().get(AUTH_SERVER_URL_CONFIG_KEY) : getAuthServerUrl(providerConfig); if (authServerUrl != null) { if (vertxInstance == 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..5050109c407cb 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 @@ -85,8 +85,8 @@ JsonRPCProvidersBuildItem produceOidcDevJsonRpcService() { @Record(ExecutionTime.RUNTIME_INIT) @BuildStep(onlyIf = IsDevelopment.class) void invokeEndpoint(BuildProducer routeProducer, - OidcDevUiRecorder recorder, - NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { + OidcDevUiRecorder recorder, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() .nestedRoute("io.quarkus.quarkus-oidc", "readSessionCookie") .handler(recorder.readSessionCookieHandler()) 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..8d78b91f0ea51 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 @@ -44,7 +44,7 @@ class OidcPropertiesState extends LitState { postLogoutUriParam: null, scopes: null, authExtraParams: null, - httpPort: 0, + httpPort: 8080, accessToken: null, idToken: null, userName: null, @@ -1022,7 +1022,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { const code = QwcOidcProvider._getQueryParameter('code'); const state = QwcOidcProvider._getQueryParameter('state'); QwcOidcProvider._exchangeCodeForTokens(code, state, jsonRpc, onUpdateDone); - } else { + } else if (propertiesState.oidcApplicationType === 'web-app') { QwcOidcProvider._checkSessionCookie(jsonRpc, () => { // logged in propertiesState.hideImplLoggedOut = true; @@ -1046,6 +1046,23 @@ export class QwcOidcProvider extends QwcHotReloadElement { propertiesState.idToken = null; onUpdateDone(); }); + } else { + // 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(); } } @@ -1123,8 +1140,9 @@ export class QwcOidcProvider extends QwcHotReloadElement { } 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) { 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..32ed015a9794a 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,7 +41,7 @@ public RuntimeValue getRpcServiceProperties(Strin } public Handler readSessionCookieHandler() { - return new OidcDevSessionCookieReaderHandler(); + return new OidcDevSessionCookieReaderHandler(oidcConfigRuntimeValue.getValue()); } public Handler logoutHandler() { 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