diff --git a/docs/src/main/asciidoc/images/dev-ui-keycloak-client-credentials-grant.png b/docs/src/main/asciidoc/images/dev-ui-keycloak-client-credentials-grant.png index 2995690b656265..e33fcbc4439d89 100644 Binary files a/docs/src/main/asciidoc/images/dev-ui-keycloak-client-credentials-grant.png and b/docs/src/main/asciidoc/images/dev-ui-keycloak-client-credentials-grant.png differ diff --git a/docs/src/main/asciidoc/images/dev-ui-keycloak-password-grant.png b/docs/src/main/asciidoc/images/dev-ui-keycloak-password-grant.png index 6a811b158c53f1..363db5c51a5213 100644 Binary files a/docs/src/main/asciidoc/images/dev-ui-keycloak-password-grant.png and b/docs/src/main/asciidoc/images/dev-ui-keycloak-password-grant.png differ diff --git a/docs/src/main/asciidoc/images/dev-ui-keycloak-sign-in-to-spa.png b/docs/src/main/asciidoc/images/dev-ui-keycloak-sign-in-to-spa.png index 1e31b0c1c8e9dd..4ceffed5df1c5b 100644 Binary files a/docs/src/main/asciidoc/images/dev-ui-keycloak-sign-in-to-spa.png and b/docs/src/main/asciidoc/images/dev-ui-keycloak-sign-in-to-spa.png differ diff --git a/docs/src/main/asciidoc/images/dev-ui-keycloak-test-access-token.png b/docs/src/main/asciidoc/images/dev-ui-keycloak-test-access-token.png new file mode 100644 index 00000000000000..6451279ee1ae93 Binary files /dev/null and b/docs/src/main/asciidoc/images/dev-ui-keycloak-test-access-token.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 65051f176f6f10..aa49ca75bbbcc9 100644 --- a/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc @@ -72,7 +72,16 @@ First you will see an option to `Log into Single Page Application`: image::dev-ui-keycloak-sign-in-to-spa.png[alt=Dev UI OpenID Connect Keycloak Page - Log into Single Page Application,role="center"] -Next, after you select this option, you will be redirected to Keycloak to authenticate, example, as `alice:alice` and then returned to the page representing the SPA: +Choose Keycloak realm and client id which will be used during the authentication process. + +[NOTE] +==== +This SPA represents a public OpenId Connect client therefore the client IDs you enter should identify public Keycloak clients which have no secrets. This is because SPA is not a web application and can not securely handle secrets which it will need to complete the authorization code flow if the client secret is also expected to complete the authorization code flow. + +The clients requiring secrets can only be supported with this SPA if a default realm has been created or if `quarkus.oidc.credentials.secret` is configued and a single custom realm is used since in these cases the SPA can figure out the client secret it may need to complete the authorization code flow after Keycloak redorected the user back to it. +==== + +Next, after selecting `Log into Single Page Application`, you will be redirected to Keycloak to authenticate, example, as `alice:alice` and then returned to the page representing the SPA: image::dev-ui-keycloak-test-service-from-spa.png[alt=Dev UI OpenID Connect Keycloak Single Page Application,role="center"] @@ -82,7 +91,15 @@ image::dev-ui-keycloak-decoded-tokens.png[alt=Dev UI OpenID Connect Keycloak Dec This view shows the encoded JWT token on the left-hand side and highlights the headers (red colour), payload/claims (green colour) and signature (blue colour). It also shows the decoded JWT token on the right-hand side where you can see the header and claim names and their values. -Next test the service with either the current access or ID token. SPA usually sends the access tokens to the application endpoints but there could be cases where the ID tokens are forwarded to the application frontends for them to be aware about the user who is currently logged into SPA. +Next test the service by entering a relative service path and sending a token. SPA usually sends access tokens to the application endpoint, so choose `Test with Access Token` option, for example: + +image::dev-ui-keycloak-test-access-token.png[alt=Dev UI Keycloak Test with access token,role="center"] + +You can use an `eraser` symbol in the right bottom corner to clear the test results area. + +Sometimes ID tokens are forwarded to the application frontends as bearer tokens as well for the endpoints be aware about the user who is currently logged into SPA or to perform an out-of-band token verification. Choose `Test with ID Token` option in such cases. + +Manually entering the service paths is not ideal, so please see the <> section about enabling Swagger or GraphQL UI for testing the service with the access token already acquired by OIDC Dev UI. Finally, you can select a `Log Out` image::dev-ui-keycloak-logout.png option if you'd like to log out and authenticate to Keycloak as a different user. @@ -92,6 +109,7 @@ image::dev-ui-keycloak-login-error.png[alt=Dev UI Keycloak Login Error,role="cen If the error occurs then log into Keycloak using the `Keycloak Admin` option and update the realm configuration as necessary and also check the `application.properties`. +[[test-with-swagger-graphql]] ===== Test with Swagger UI or GraphQL UI You can avoid manually entering the service paths and test your service with `Swagger UI` or `GraphQL UI` if `quarkus-smallrye-openapi` and/or `quarkus-smallrye-graphql` are used in your project. For example, if you start Quarkus in dev mode with both `quarkus-smallrye-openapi` and `quarkus-smallrye-graphql` dependencies then you will see the following options after logging in into Keycloak: @@ -99,7 +117,7 @@ You can avoid manually entering the service paths and test your service with `Sw image::dev-ui-keycloak-test-service-swaggerui-graphql.png[alt=Test your service with Swagger UI or GraphQL UI,role="center"] For example, clicking on `Swagger UI` will open `Swagger UI` in a new browser tab where you can test the service using the token acquired by Dev UI for Keycloak. -and `Swagger UI` will not try to re-authenticate again. +and `Swagger UI` will not try to re-authenticate again. Do not choose a `Swagger UI` `Authorize` option once you are in Swagger UI since OIDC Dev UI has done the authorization and provided the access token for Swagger UI to use for testing. Integration with `GraphQL UI` works in a similar way, the access token acquired by Dev UI for Keycloak will be used. @@ -128,7 +146,7 @@ If you set `quarkus.oidc.devui.grant.type=password` in `application.properties` image::dev-ui-keycloak-password-grant.png[alt=Dev UI OpenID Connect Keycloak Page - Password Grant,role="center"] -Enter a registered username, user password, a relative service endpoint path, click on `Test Service` and you will see a status code such as `200`, `403`, `401` or `404` printed. +Select a realm, enter client id and secret, username amd user password, a relative service endpoint path, click on `Test Service` and you will see a status code such as `200`, `403`, `401` or `404` printed. If the username is also set in `quarkus.keycloak.devservices.users` map property containing usernames and passwords then you do not have to set a password when testing the service. But note, you do not have to initialize `quarkus.keycloak.devservices.users` to test the service using the password grant. @@ -150,7 +168,7 @@ If you set `quarkus.oidc.devui.grant.type=client` then a `client_credentials` gr image::dev-ui-keycloak-client-credentials-grant.png[alt=Dev UI OpenID Connect Keycloak Page - Client Credentials Grant,role="center"] -You can test the service the same way as with the `Password` grant. +Select a realm, enter the client id and secret, a relative service endpoint path, click on `Test Service` and you will see a status code such as `200`, `403`, `401` or `404` printed. [[develop-web-app-applications]] === Developing OpenID Connect Web App Applications @@ -198,8 +216,8 @@ Please see xref:security-openid-connect.adoc#integration-testing-keycloak-devser [[keycloak-initialization]] === Keycloak Initialization -The `quay.io/keycloak/keycloak:17.0.0` image which contains a Keycloak distribution powered by Quarkus is used to start a container by default. -`quarkus.keycloak.devservices.image-name` can be used to change the Keycloak image name. For example, set it to `quay.io/keycloak/keycloak:17.0.0-legacy` to use a Keycloak distribution powered by WildFly. +The `quay.io/keycloak/keycloak:19.0.2` image which contains a Keycloak distribution powered by Quarkus is used to start a container by default. +`quarkus.keycloak.devservices.image-name` can be used to change the Keycloak image name. For example, set it to `quay.io/keycloak/keycloak:19.0.0-legacy` to use a Keycloak distribution powered by WildFly. `Dev Services for Keycloak` will initialize a launched Keycloak server next. @@ -225,7 +243,18 @@ This configuration creates two users: However, it is likely your Keycloak configuration may be more complex and require setting more properties. -This is why `quarkus.keycloak.devservices.realm-path` is always checked first before trying to initialize Keycloak with the default or configured realm, client, user and roles properties. If the realm file exists on the file system or classpath then only this realm will be used to initialize Keycloak. +This is why `quarkus.keycloak.devservices.realm-path` is always checked first before trying to initialize Keycloak with the default or configured realm, client, user and roles properties. If the realm file exists on the file system or classpath then only this realm will be used to initialize Keycloak, for example: + +[source,properties] +---- +quarkus.keycloak.devservices.realm-path=quarkus-realm.json +---- + +You can use `quarkus.keycloak.devservices.realm-path` to initialize Keycloak with multiple realm files by providing a comma-separated list of files: + +---- +quarkus.keycloak.devservices.realm-path=quarkus-realm1.json,quarkus-realm2.json +---- Also, the Keycloak page offers an option to `Sign In To Keycloak To Configure Realms` using a `Keycloak Admin` option in the right top corner: @@ -233,8 +262,6 @@ image::dev-ui-keycloak-admin.png[alt=Dev UI OpenID Connect Keycloak Page - Keycl Sign in to Keycloak as `admin:admin` in order to further customize the realm properties, create or import a new realm, export the realm. -Note that even if you initialize Keycloak from a realm file, it is still needed to set `quarkus.keycloak.devservices.users` property if a `password` grant is used to acquire the tokens to test the OIDC `service` applications. - == Disable Dev Services for Keycloak `Dev Services For Keycloak` will not be activated if either `quarkus.oidc.auth-server-url` is already initialized or the default OIDC tenant is disabled with `quarkus.oidc.tenant.enabled=false`, irrespectively of whether you work with Keycloak or not. diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java index 08f34baaf2e6fb..cd580975597c59 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/DevServicesConfig.java @@ -78,10 +78,11 @@ public class DevServicesConfig { public String serviceName; /** - * The class or file system path to a Keycloak realm file which will be used to initialize Keycloak. + * The comma-separated list of class or file system paths to Keycloak realm files which will be used to initialize Keycloak. + * The first value in this list will be used to initialize default tenant connection properties. */ @ConfigItem - public Optional realmPath; + public Optional> realmPath; /** * The JAVA_OPTS passed to the keycloak JVM diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevConsoleProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevConsoleProcessor.java index 5047e5f2b8160a..38b17975a97f16 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevConsoleProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevConsoleProcessor.java @@ -36,6 +36,9 @@ public void setConfigProperties(BuildProducer d devConsoleInfo.produce( new DevConsoleTemplateInfoBuildItem("keycloakUsers", configProps.get().getProperties().get("oidc.users"))); + devConsoleInfo.produce( + new DevConsoleTemplateInfoBuildItem("keycloakRealms", + configProps.get().getProperties().get("keycloak.realms"))); String realmUrl = configProps.get().getConfig().get("quarkus.oidc.auth-server-url"); produceDevConsoleTemplateItems(capabilities, diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java index dbdc5dbd8ec3c2..1f7ed7a2f8fca8 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevServicesProcessor.java @@ -18,6 +18,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -36,7 +37,6 @@ import org.keycloak.representations.idm.RolesRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.util.JsonSerialization; -import org.testcontainers.containers.BindMode; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; @@ -87,6 +87,7 @@ public class KeycloakDevServicesProcessor { 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 final String KEYCLOAK_URL_KEY = "keycloak.url"; + private static final String CLIENT_KEYCLOAK_URL_KEY = "client." + KEYCLOAK_URL_KEY; private static final String KEYCLOAK_CONTAINER_NAME = "keycloak"; private static final int KEYCLOAK_PORT = 8080; @@ -100,7 +101,6 @@ public class KeycloakDevServicesProcessor { private static final String KEYCLOAK_WILDFLY_FRONTEND_URL = "KEYCLOAK_FRONTEND_URL"; private static final String KEYCLOAK_WILDFLY_USER_PROP = "KEYCLOAK_USER"; private static final String KEYCLOAK_WILDFLY_PASSWORD_PROP = "KEYCLOAK_PASSWORD"; - private static final String KEYCLOAK_WILDFLY_IMPORT_PROP = "KEYCLOAK_IMPORT"; private static final String KEYCLOAK_WILDFLY_DB_VENDOR = "H2"; private static final String KEYCLOAK_WILDFLY_VENDOR_PROP = "DB_VENDOR"; @@ -111,8 +111,8 @@ public class KeycloakDevServicesProcessor { private static final String KEYCLOAK_QUARKUS_START_CMD = "start --storage=chm --http-enabled=true --hostname-strict=false --hostname-strict-https=false"; private static final String JAVA_OPTS = "JAVA_OPTS"; - private static final String KEYCLOAK_DOCKER_REALM_PATH = "/tmp/realm.json"; private static final String OIDC_USERS = "oidc.users"; + private static final String KEYCLOAK_REALMS = "keycloak.realms"; /** * Label to add to shared Dev Service for Keycloak running in containers. @@ -153,13 +153,13 @@ public DevServicesResultBuildItem startKeycloakContainer( if (devService != null) { boolean restartRequired = !currentDevServicesConfiguration.equals(capturedDevServicesConfiguration); if (!restartRequired) { - FileTime currentRealmFileLastModifiedDate = getRealmFileLastModifiedDate( - currentDevServicesConfiguration.realmPath); - if (currentRealmFileLastModifiedDate != null - && !currentRealmFileLastModifiedDate.equals(capturedRealmFileLastModifiedDate)) { - restartRequired = true; - capturedRealmFileLastModifiedDate = currentRealmFileLastModifiedDate; - } + //FileTime currentRealmFileLastModifiedDate = getRealmFileLastModifiedDate( + // currentDevServicesConfiguration.realmPath); + //if (currentRealmFileLastModifiedDate != null + // && !currentRealmFileLastModifiedDate.equals(capturedRealmFileLastModifiedDate)) { + // restartRequired = true; + // capturedRealmFileLastModifiedDate = currentRealmFileLastModifiedDate; + //} } if (!restartRequired) { DevServicesResultBuildItem result = devService.toBuildItem(); @@ -167,8 +167,12 @@ public DevServicesResultBuildItem startKeycloakContainer( Map users = (usersString == null || usersString.isBlank()) ? Map.of() : Arrays.stream(usersString.split(",")) .map(s -> s.split("=")).collect(Collectors.toMap(s -> s[0], s -> s[1])); + String realmsString = result.getConfig().get(KEYCLOAK_REALMS); + List realms = (realmsString == null || realmsString.isBlank()) ? List.of() + : Arrays.stream(realmsString.split(",")).collect(Collectors.toList()); keycloakBuildItemBuildProducer - .produce(new KeycloakDevServicesConfigBuildItem(result.getConfig(), Map.of(OIDC_USERS, users))); + .produce(new KeycloakDevServicesConfigBuildItem(result.getConfig(), + Map.of(OIDC_USERS, users, KEYCLOAK_REALMS, realms))); return result; } try { @@ -226,7 +230,7 @@ public void run() { closeBuildItem.addCloseTask(closeTask, true); } - capturedRealmFileLastModifiedDate = getRealmFileLastModifiedDate(capturedDevServicesConfiguration.realmPath); + //capturedRealmFileLastModifiedDate = getRealmFileLastModifiedDate(capturedDevServicesConfiguration.realmPath); if (devService == null) { compressor.closeAndDumpCaptured(); } else { @@ -247,28 +251,35 @@ private String startURL(String host, Integer port, boolean isKeyCloakX) { private Map prepareConfiguration( BuildProducer keycloakBuildItemBuildProducer, String internalURL, - String hostURL, RealmRepresentation realmRep, + String hostURL, List realmReps, boolean keycloakX) { - final String realmName = realmRep != null ? realmRep.getRealm() : getDefaultRealmName(); + final String realmName = !realmReps.isEmpty() ? realmReps.iterator().next().getRealm() : getDefaultRealmName(); final String authServerInternalUrl = realmsURL(internalURL, realmName); String clientAuthServerBaseUrl = hostURL != null ? hostURL : internalURL; String clientAuthServerUrl = realmsURL(clientAuthServerBaseUrl, realmName); - String oidcClientId = getOidcClientId(); - String oidcClientSecret = getOidcClientSecret(); + boolean createDefaultRealm = realmReps.isEmpty() && capturedDevServicesConfiguration.createRealm; + + String oidcClientId = getOidcClientId(createDefaultRealm); + String oidcClientSecret = getOidcClientSecret(createDefaultRealm); String oidcApplicationType = getOidcApplicationType(); - boolean createDefaultRealm = realmRep == null && capturedDevServicesConfiguration.createRealm; Map users = getUsers(capturedDevServicesConfiguration.users, createDefaultRealm); + List realmNames = new LinkedList<>(); + WebClient client = OidcDevServicesUtils.createWebClient(vertxInstance); try { String adminToken = getAdminToken(client, clientAuthServerBaseUrl); if (createDefaultRealm) { createDefaultRealm(client, adminToken, clientAuthServerBaseUrl, users, oidcClientId, oidcClientSecret); - } else if (realmRep != null && keycloakX) { - createRealm(client, adminToken, clientAuthServerBaseUrl, realmRep); + realmNames.add(realmName); + } else { + for (RealmRepresentation realmRep : realmReps) { + createRealm(client, adminToken, clientAuthServerBaseUrl, realmRep); + realmNames.add(realmRep.getRealm()); + } } } finally { client.close(); @@ -276,6 +287,7 @@ private Map prepareConfiguration( Map configProperties = new HashMap<>(); configProperties.put(KEYCLOAK_URL_KEY, internalURL); + configProperties.put(CLIENT_KEYCLOAK_URL_KEY, clientAuthServerBaseUrl); configProperties.put(AUTH_SERVER_URL_CONFIG_KEY, authServerInternalUrl); configProperties.put(CLIENT_AUTH_SERVER_URL_CONFIG_KEY, clientAuthServerUrl); configProperties.put(APPLICATION_TYPE_CONFIG_KEY, oidcApplicationType); @@ -283,9 +295,11 @@ private Map prepareConfiguration( configProperties.put(CLIENT_SECRET_CONFIG_KEY, oidcClientSecret); configProperties.put(OIDC_USERS, users.entrySet().stream() .map(e -> e.toString()).collect(Collectors.joining(","))); + configProperties.put(KEYCLOAK_REALMS, realmNames.stream().collect(Collectors.joining(","))); keycloakBuildItemBuildProducer - .produce(new KeycloakDevServicesConfigBuildItem(configProperties, Map.of(OIDC_USERS, users))); + .produce(new KeycloakDevServicesConfigBuildItem(configProperties, + Map.of(OIDC_USERS, users, KEYCLOAK_REALMS, realmNames))); return configProperties; } @@ -337,7 +351,7 @@ private RunningDevService startContainer(DockerStatusBuildItem dockerStatusBuild QuarkusOidcContainer oidcContainer = new QuarkusOidcContainer(dockerImageName, capturedDevServicesConfiguration.port, useSharedNetwork, - capturedDevServicesConfiguration.realmPath, + capturedDevServicesConfiguration.realmPath.orElse(List.of()), capturedDevServicesConfiguration.serviceName, capturedDevServicesConfiguration.shared, capturedDevServicesConfiguration.javaOpts, @@ -353,7 +367,7 @@ private RunningDevService startContainer(DockerStatusBuildItem dockerStatusBuild : null; Map configs = prepareConfiguration(keycloakBuildItemBuildProducer, internalUrl, hostUrl, - oidcContainer.realmRep, + oidcContainer.realmReps, oidcContainer.keycloakX); return new RunningDevService(KEYCLOAK_CONTAINER_NAME, oidcContainer.getContainerId(), oidcContainer::close, configs); @@ -384,23 +398,23 @@ private String getSharedContainerUrl(ContainerAddress containerAddress) { private static class QuarkusOidcContainer extends GenericContainer { private final OptionalInt fixedExposedPort; private final boolean useSharedNetwork; - private final Optional realmPath; + private final List realmPaths; private final String containerLabelValue; private final Optional javaOpts; private final boolean sharedContainer; private String hostName; private final boolean keycloakX; - private RealmRepresentation realmRep; + private List realmReps = new LinkedList<>(); private final Optional startCommand; private final boolean showLogs; public QuarkusOidcContainer(DockerImageName dockerImageName, OptionalInt fixedExposedPort, boolean useSharedNetwork, - Optional realmPath, String containerLabelValue, + List realmPaths, String containerLabelValue, boolean sharedContainer, Optional javaOpts, Optional startCommand, boolean showLogs) { super(dockerImageName); this.useSharedNetwork = useSharedNetwork; - this.realmPath = realmPath; + this.realmPaths = realmPaths; this.containerLabelValue = containerLabelValue; this.sharedContainer = sharedContainer; this.javaOpts = javaOpts; @@ -458,31 +472,21 @@ protected void configure() { addEnv(KEYCLOAK_WILDFLY_VENDOR_PROP, KEYCLOAK_WILDFLY_DB_VENDOR); } - if (realmPath.isPresent()) { + for (String realmPath : realmPaths) { URL realmPathUrl = null; - if ((realmPathUrl = Thread.currentThread().getContextClassLoader().getResource(realmPath.get())) != null) { - realmRep = readRealmFile(realmPathUrl, realmPath.get()); - if (!keycloakX) { - withClasspathResourceMapping(realmPath.get(), KEYCLOAK_DOCKER_REALM_PATH, BindMode.READ_ONLY); - } + if ((realmPathUrl = Thread.currentThread().getContextClassLoader().getResource(realmPath)) != null) { + readRealmFile(realmPathUrl, realmPath).ifPresent(realmRep -> realmReps.add(realmRep)); } else { - Path filePath = Paths.get(realmPath.get()); + Path filePath = Paths.get(realmPath); if (Files.exists(filePath)) { - if (!keycloakX) { - withFileSystemBind(realmPath.get(), KEYCLOAK_DOCKER_REALM_PATH, BindMode.READ_ONLY); - } - realmRep = readRealmFile(filePath.toUri(), realmPath.get()); + readRealmFile(filePath.toUri(), realmPath).ifPresent(realmRep -> realmReps.add(realmRep)); } else { - LOG.debugf("Realm %s resource is not available", realmPath.get()); + LOG.debugf("Realm %s resource is not available", realmPath); } } } - if (realmRep != null && !keycloakX) { - addEnv(KEYCLOAK_WILDFLY_IMPORT_PROP, KEYCLOAK_DOCKER_REALM_PATH); - } - if (showLogs) { super.withLogConsumer(t -> { LOG.info("Keycloak: " + t.getUtf8String()); @@ -500,7 +504,7 @@ private Integer findRandomPort() { } } - private RealmRepresentation readRealmFile(URI uri, String realmPath) { + private Optional readRealmFile(URI uri, String realmPath) { try { return readRealmFile(uri.toURL(), realmPath); } catch (MalformedURLException ex) { @@ -509,15 +513,15 @@ private RealmRepresentation readRealmFile(URI uri, String realmPath) { } } - private RealmRepresentation readRealmFile(URL url, String realmPath) { + private Optional readRealmFile(URL url, String realmPath) { try { try (InputStream is = url.openStream()) { - return JsonSerialization.readValue(is, RealmRepresentation.class); + return Optional.of(JsonSerialization.readValue(is, RealmRepresentation.class)); } } catch (IOException ex) { LOG.errorf("Realm %s resource can not be opened: %s", realmPath, ex.getMessage()); } - return null; + return Optional.empty(); } @Override @@ -596,6 +600,7 @@ private void createRealm(WebClient client, String token, String keycloakUrl, Rea .transform(resp -> { LOG.debugf("Realm status: %d", resp.statusCode()); if (resp.statusCode() == 200) { + LOG.debugf("Realm %s has been created", realm.getRealm()); return 200; } else { throw new RealmEndpointAccessException(resp.statusCode()); @@ -726,11 +731,13 @@ 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 getOidcClientId(boolean createRealm) { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_ID_CONFIG_KEY, String.class) + .orElse(createRealm ? "quarkus-app" : ""); } - private static String getOidcClientSecret() { - return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class).orElse("secret"); + private static String getOidcClientSecret(boolean createRealm) { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class) + .orElse(createRealm ? "secret" : ""); } } diff --git a/extensions/oidc/deployment/src/main/resources/dev-templates/provider.html b/extensions/oidc/deployment/src/main/resources/dev-templates/provider.html index a18c17d2d36563..efd73823816177 100644 --- a/extensions/oidc/deployment/src/main/resources/dev-templates/provider.html +++ b/extensions/oidc/deployment/src/main/resources/dev-templates/provider.html @@ -41,7 +41,8 @@ $('.implicitLoggedIn').show(); var search = window.location.search; var code = search.match(/code=([^&]+)/)[1]; - exchangeCodeForTokens(code); + var state = search.match(/state=([^&]+)/)[1]; + exchangeCodeForTokens(code, state); }else if(errorInUrl()){ loggedIn === false; $('.implicitLoggedOut').hide(); @@ -99,20 +100,30 @@ } function signInToOidcProviderAndGetTokens() { + var address; + var state; + var clientId = getClientId(); + {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + address = '{info:keycloakAdminUrl??}' + "/realms/" + $('#keycloakRealm').val() + "/protocol/openid-connect/auth"; + state = makeid() + "_" + $('#keycloakRealm').val() + "_" + clientId; + {#else} + address = '{info:authorizationUrl??}'; + state = makeid(); + {/if} {#if info:oidcGrantType is 'implicit'} - window.location.href = '{info:authorizationUrl??}' - + "?client_id=" + '{info:clientId}' + window.location.href = address + + "?client_id=" + clientId + "&redirect_uri=" + "http%3A%2F%2Flocalhost%3A" + port + encodedDevRoot + "%2Fio.quarkus.quarkus-oidc%2Fprovider" + "&scope=openid&response_type=token id_token&response_mode=query&prompt=login" + "&nonce=" + makeid() - + "&state=" + makeid(); + + "&state=" + state; {#else} - window.location.href = '{info:authorizationUrl??}' - + "?client_id=" + '{info:clientId}' + window.location.href = address + + "?client_id=" + clientId + "&redirect_uri=" + "http%3A%2F%2Flocalhost%3A" + port + encodedDevRoot + "%2Fio.quarkus.quarkus-oidc%2Fprovider" + "&scope=openid&response_type=code&response_mode=query&prompt=login" + "&nonce=" + makeid() - + "&state=" + makeid(); + + "&state=" + state; {/if} } @@ -188,16 +199,30 @@ function logout() { localStorage.removeItem('authorized'); - window.location.assign('{info:logoutUrl??}' + var address; + {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + address = '{info:keycloakAdminUrl??}' + "/realms/" + $('#keycloakRealm').val() + "/protocol/openid-connect/logout"; + {#else} + address = '{info:logoutUrl??}'; + {/if} + window.location.assign(address + "?" + '{info:postLogoutUriParam??}' + "=" + "http%3A%2F%2Flocalhost%3A" + port + encodedDevRoot + "%2Fio.quarkus.quarkus-oidc%2Fprovider" + "&" + "id_token_hint" + "=" + idToken); } - function exchangeCodeForTokens(code){ + function exchangeCodeForTokens(code, state){ + var address = '{info:tokenUrl??}'; + var clientId = '{info:clientId??}'; + if (state && state.includes("_")) { + var parts = state.substring(index + 1).split("_"); + var index = address.indexOf("/realms/"); + address = address.substring(0, index + 8) + parts[1] + "/protocol/openid-connect/token"; + clientId = parts[2]; + } $.post("exchangeCodeForTokens", { - tokenUrl: '{info:tokenUrl??}', - client: '{info:clientId}', + tokenUrl: address, + client: clientId, clientSecret: '{info:clientSecret}', authorizationCode: code, redirectUri: "http://localhost:" + port + devRoot + "/io.quarkus.quarkus-oidc/provider" @@ -262,6 +287,7 @@ } {/if} + {/if} {#if info:oidcApplicationType is 'web-app'} @@ -275,10 +301,10 @@ function testServiceWithPassword(userName, password, servicePath){ $.post("testService", { - tokenUrl: '{info:tokenUrl??}', + tokenUrl: getTokenUrl(), serviceUrl: "http://localhost:" + port + servicePath, - client: '{info:clientId}', - clientSecret: '{info:clientSecret}', + client: getClientId(), + clientSecret: getClientSecret(), user: userName, password: password, grant: '{info:oidcGrantType}' @@ -291,9 +317,9 @@ function testServiceWithPasswordInSwaggerUi(userName, password){ $.post("testService", { - tokenUrl: '{info:tokenUrl??}', - client: '{info:clientId}', - clientSecret: '{info:clientSecret}', + tokenUrl: getTokenUrl(), + client: getClientId(), + clientSecret: getClientSecret(), user: userName, password: password, grant: '{info:oidcGrantType}' @@ -306,9 +332,9 @@ function testServiceWithPasswordInGraphQLUi(userName){ $.post("testService", { - tokenUrl: '{info:tokenUrl??}', - client: '{info:clientId}', - clientSecret: '{info:clientSecret}', + tokenUrl: getTokenUrl(), + client: getClientId(), + clientSecret: getClientSecret(), user: userName, grant: '{info:oidcGrantType}' }, @@ -322,10 +348,10 @@ function testServiceWithClientCredentials(servicePath) { $.post("testService", { - tokenUrl: '{info:tokenUrl??}', + tokenUrl: getTokenUrl(), serviceUrl: "http://localhost:" + port + servicePath, - client: '{info:clientId}', - clientSecret: '{info:clientSecret}', + client: getClientId(), + clientSecret: getClientSecret(), grant: '{info:oidcGrantType}' }, function(data, status){ @@ -335,9 +361,9 @@ function testServiceWithClientCredentialsInSwaggerUi(){ $.post("testService", { - tokenUrl: '{info:tokenUrl??}', - client: '{info:clientId}', - clientSecret: '{info:clientSecret}', + tokenUrl: getTokenUrl(), + client: getClientId(), + clientSecret: getClientSecret(), grant: '{info:oidcGrantType}' }, function(data, status){ @@ -348,9 +374,9 @@ function testServiceWithClientCredentialsInGraphQLUi(){ $.post("testService", { - tokenUrl: '{info:tokenUrl??}', - client: '{info:clientId}', - clientSecret: '{info:clientSecret}', + tokenUrl: getTokenUrl(), + client: getClientId(), + clientSecret: getClientSecret(), grant: '{info:oidcGrantType}' }, function(data, status){ @@ -413,10 +439,34 @@ return servicePath.startsWith("/") ? servicePath : ("/" + servicePath); } +function getTokenUrl() { + {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + return '{info:keycloakAdminUrl??}' + "/realms/" + $('#keycloakRealm').val() + "/protocol/openid-connect/token"; + {#else} + return '{info:tokenUrl??}'; + {/if} +} + function clearResults() { $('#results').text(''); } +function getClientId() { + {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + return $('#clientId').val(); + {#else} + return '{info:clientId??}'; + {/if} +} + +function getClientSecret() { + {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + return $('#clientSecret').val(); + {#else} + return '{info:clientSecret??}'; + {/if} +} + {/script} {#body} @@ -436,6 +486,33 @@
+ {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + {#let realms=info:keycloakRealms??} +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ {/let} + {/if} Log into Single Page Application @@ -589,6 +666,42 @@
Decoded
Get access token and test your service
+ {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + {#let realms=info:keycloakRealms??} +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ {/let} + {/if}
@@ -643,9 +756,45 @@
Decoded
{#else if info:oidcGrantType is 'client_credentials'}
- Get access token for the client {info:clientId} and test your service + Get access token for the client and test your service
+ {#if info:keycloakAdminUrl?? && info:keycloakRealms??} + {#let realms=info:keycloakRealms??} +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ {/let} + {/if}
diff --git a/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java b/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java index 13335c8a8dd2bc..b052998ecb909c 100644 --- a/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java +++ b/test-framework/keycloak-server/src/main/java/io/quarkus/test/keycloak/client/KeycloakTestClient.java @@ -32,7 +32,7 @@ public KeycloakTestClient() { } /** - * Get an access token using a password grant with a provided user name. + * Get an access token from the default tenant realm using a password grant with a provided user name. * User secret will be the same as the user name, client id will be set to 'quarkus-app' and client secret to 'secret'. */ public String getAccessToken(String userName) { @@ -40,7 +40,7 @@ public String getAccessToken(String userName) { } /** - * Get an access token using a password grant with the provided user name and client id. + * Get an access token from the default tenant realm using a password grant with the provided user name and client id. * User secret will be the same as the user name, client secret will be set to 'secret'. */ public String getAccessToken(String userName, String clientId) { @@ -48,7 +48,8 @@ public String getAccessToken(String userName, String clientId) { } /** - * Get an access token using a password grant with the provided user name, user secret and client id. + * Get an access token from the default tenant realm using a password grant with the provided user name, user secret and + * client id. * Client secret will be set to 'secret'. */ public String getAccessToken(String userName, String userSecret, String clientId) { @@ -56,13 +57,47 @@ public String getAccessToken(String userName, String userSecret, String clientId } /** - * Get an access token using a password grant with the provided user name, user secret, client id and secret. + * Get an access token from the default tenant realm using a password grant with the provided user name, user secret, client + * id and secret. * Set the client secret to an empty string or null if it is not required. */ public String getAccessToken(String userName, String userSecret, String clientId, String clientSecret) { return getAccessTokenInternal(userName, userSecret, clientId, clientSecret, getAuthServerUrl()); } + /** + * Get a realm access token using a password grant with a provided user name. + * User secret will be the same as the user name, client id will be set to 'quarkus-app' and client secret to 'secret'. + */ + public String getRealmAccessToken(String realm, String userName) { + return getRealmAccessToken(realm, userName, getClientId()); + } + + /** + * Get a realm access token using a password grant with the provided user name and client id. + * User secret will be the same as the user name, client secret will be set to 'secret'. + */ + public String getRealmAccessToken(String realm, String userName, String clientId) { + return getRealmAccessToken(realm, userName, userName, clientId); + } + + /** + * Get a realm access token using a password grant with the provided user name, user secret and client id. + * Client secret will be set to 'secret'. + */ + public String getRealmAccessToken(String realm, String userName, String userSecret, String clientId) { + return getRealmAccessToken(realm, userName, userSecret, clientId, getClientSecret()); + } + + /** + * Get a realm access token using a password grant with the provided user name, user secret, client id and secret. + * Set the client secret to an empty string or null if it is not required. + */ + public String getRealmAccessToken(String realm, String userName, String userSecret, String clientId, String clientSecret) { + return getAccessTokenInternal(userName, userSecret, clientId, clientSecret, + getAuthServerBaseUrl() + "/realms/" + realm); + } + private String getAccessTokenInternal(String userName, String userSecret, String clientId, String clientSecret, String authServerUrl) { RequestSpecification requestSpec = RestAssured.given().param("grant_type", "password")