diff --git a/.github/native-tests.json b/.github/native-tests.json index cd9b3beb494a91..ee6584b2b45f45 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 5f4dcdca4494ac..74b972f8d4a15d 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 00000000000000..48ed2a6604f72b Binary files /dev/null and b/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-for-custom-users.png differ 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 00000000000000..7d923c72623e0f Binary files /dev/null and b/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-page.png differ 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 dcdbdbeac04150..2cc1425252e8b7 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: @@ -406,6 +407,46 @@ 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 the Dev UI 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"] + +As always, default `alice` roles are `admin` and `user`, while default `bob` role is `user`. +Nevertheless, you can configure roles according to your preference: + +[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 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, password is not 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 20857559b3c47d..9a0087eea9e263 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 6227b263b3125d..8709a2a76b2c26 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 00000000000000..ec198748674a61 --- /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 00000000000000..e97eef86dad8d0 --- /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 30d9fac042b9bc..14fc63be89258b 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 00000000000000..1729e3ad64e706 --- /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 5f0851f718f7ab..fc3e4fae0d1237 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 8e8796c16474ff..8f61153678894d 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 1385aa5929874f..5378b6a02f419c 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,22 +3,29 @@ 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; 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, @@ -111,4 +118,39 @@ private static String getProperty(ConfigurationBuildItem configurationBuildItem, return propertyValue; } + + 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()); + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "login") + .handler(recorder.loginHandler()) + .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 def279cb26ad3f..7223bdc5e018f0 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 8cd1673a9fc404..c8468c7f002e02 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 c122e00272501f..00000000000000 --- 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 2b1e92e9f9539c..4def29c9866cf6 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,20 +1,19 @@ -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'; +import '@vaadin/checkbox'; 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 +43,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, + servicePathRequired: true, + webAppLoginObserver: null }; } @@ -78,6 +79,12 @@ class OidcPropertiesState extends LitState { propertiesState.authExtraParams = response.result.authExtraParams; propertiesState.httpPort = response.result.httpPort; propertiesState.oidcProviderName = response.result.oidcProviderName; + if (propertiesState.oidcApplicationType !== response.result.oidcApplicationType + && response.result.oidcApplicationType === 'web-app') { + // default OIDC application type has changed and this is a web app + // reset checkbox to default + propertiesState.servicePathRequired = false + } propertiesState.oidcApplicationType = response.result.oidcApplicationType; propertiesState.oidcGrantType = response.result.oidcGrantType; propertiesState.swaggerIsAvailable = response.result.swaggerIsAvailable; @@ -232,9 +239,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; } `; @@ -248,13 +257,12 @@ export class QwcOidcProvider extends QwcHotReloadElement { _selectedClientId: {state: false, type: String}, _selectedClientSecret: {state: false, type: String}, _servicePath: {state: false, type: String}, - _devRoot: {state: false, type: String} + _devRoot: {state: false, type: String}, }; constructor() { super(); this._devRoot = (devRoot?.replaceAll('/', '%2F') ?? '') + 'dev-ui'; // e.g. /q/dev-ui - this._selectedRealm = null; this._servicePath = '/'; this._selectedClientId = null; @@ -300,6 +308,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'idToken'); propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'testServiceResponses'); propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'accessToken'); + propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'servicePathRequired'); super.connectedCallback(); QwcOidcProvider._loadProperties(this.jsonRpc) @@ -373,6 +382,16 @@ export class QwcOidcProvider extends QwcHotReloadElement { return html` + + ${servicePathForm} { - // 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 +1145,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 +1265,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { const clientId = this._getClientId(); let address; - if (propertiesState.keycloakAdminUrl && this._selectedRealm) { + if (!QwcOidcProvider._isWebApp() && propertiesState.keycloakAdminUrl && this._selectedRealm) { address = propertiesState.keycloakAdminUrl + '/realms/' + this._selectedRealm + '/protocol/openid-connect/logout'; } else { address = propertiesState.logoutUrl; @@ -1215,8 +1286,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 +1297,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 +1322,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 +1352,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { // decorate key var tooltip = tooltips[k]; if(tooltip) { - ret += "\"" + k + "\""; + ret += "\"" + k + "\""; } else { ret += "\"" + k + "\""; } @@ -1277,7 +1360,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 +1380,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 +1399,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 2ab60d98f32fde..2944e03ffa7ae8 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 00000000000000..cc5ab5b683d6b3 --- /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 32401f5bb49f25..cba0d21d1de04d 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/OidcDevSessionLoginHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLoginHandler.java new file mode 100644 index 00000000000000..708131edc5e14e --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLoginHandler.java @@ -0,0 +1,91 @@ +package io.quarkus.oidc.runtime.devui; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.net.URLEncoder; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; + +final class OidcDevSessionLoginHandler implements Handler { + + private static final String REDIRECT_URI_PARAM = "oidc-provider-redirect-uri"; + + /** + * Quarkus OIDC provider card page URI. + */ + private volatile String providerRedirectUri; + + @Override + public void handle(RoutingContext rc) { + // this method enforces authentication and redirects back to the original page + // it is used when you click on the "login" button + + if (redirectUriMissing(rc)) { + // our OIDC provider page JS should set this param + rc.fail(500, new IllegalStateException("Query param 'oidc-provider-redirect-uri' is missing")); + return; + } + + HttpAuthenticator httpAuthenticator = rc.get(HttpAuthenticator.class.getName()); + if (httpAuthenticator != null) { + QuarkusHttpUser + .getSecurityIdentity(rc, null) + .onItem().transformToUni(identity -> { + if (identity != null && !identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } else { + // most likely no credentials and we need to send challenge + return Uni.createFrom().failure(new UnauthorizedException()); + } + }) + .subscribe().with(identity -> redirectBackToOidcProviderCardPage(rc, null), + ignored -> httpAuthenticator.sendChallenge(rc).subscribe().with(challengeResult -> { + // challenge redirecting to OIDC provider + if (!rc.response().ended()) { + rc.response().end(); + } + }, challengeFailure -> { + // this shouldn't happen, illegal state + if (!rc.response().ended()) { + redirectBackToOidcProviderCardPage(rc, challengeFailure.getMessage()); + } + })); + } else { + // this will only happen if there is a bug in Quarkus + redirectBackToOidcProviderCardPage(rc, "Failed to authenticate as HttpAuthenticator is missing"); + } + } + + private boolean redirectUriMissing(RoutingContext rc) { + if (providerRedirectUri == null) { + final String requestRedirectUri = rc.request().getParam(REDIRECT_URI_PARAM); + if (requestRedirectUri == null) { + return true; + } else { + providerRedirectUri = requestRedirectUri; + } + } + return false; + } + + private void redirectBackToOidcProviderCardPage(RoutingContext rc, String errorDescription) { + final String oidcProviderCardPage; + if (errorDescription != null) { + oidcProviderCardPage = providerRedirectUri + "?error_description=" + URLEncoder.encode(errorDescription, UTF_8); + } else { + oidcProviderCardPage = providerRedirectUri; + } + rc + .response() + .setStatusCode(HttpResponseStatus.FOUND.code()) + .putHeader(HttpHeaders.LOCATION, oidcProviderCardPage) + .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 index d6ae3cb7c8cb56..49d924add38364 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 5c31936dbdfb1b..474989627d6e51 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,14 @@ public RuntimeValue getRpcServiceProperties(Strin } public Handler readSessionCookieHandler() { - return new OidcDevSessionCookieReaderHandler(); + return new OidcDevSessionCookieReaderHandler(oidcConfigRuntimeValue.getValue()); } public Handler logoutHandler() { return new OidcDevSessionLogoutHandler(); } + + public Handler loginHandler() { + return new OidcDevSessionLoginHandler(); + } } diff --git a/integration-tests/oidc-dev-services/pom.xml b/integration-tests/oidc-dev-services/pom.xml new file mode 100644 index 00000000000000..eace1af7f77417 --- /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 00000000000000..dcc3a947e72a94 --- /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 00000000000000..636d87caec1ef2 --- /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 00000000000000..2bf25252718b73 --- /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 00000000000000..eac0592af5e076 --- /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 00000000000000..1ff5e97a196667 --- /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 6376d450f75faa..55f67dc439690f 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