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 2995690b65626..e33fcbc4439d8 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 6a811b158c53f..363db5c51a521 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 1e31b0c1c8e9d..4ceffed5df1c5 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 0000000000000..6451279ee1ae9 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 eba4429548344..34d18a0f07b73 100644 --- a/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc @@ -73,7 +73,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"] @@ -83,7 +92,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. @@ -93,6 +110,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: @@ -100,7 +118,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. @@ -129,7 +147,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. @@ -151,7 +169,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 @@ -199,8 +217,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.2-legacy` to use a Keycloak distribution powered by WildFly. `Dev Services for Keycloak` will initialize a launched Keycloak server next. @@ -226,7 +244,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: @@ -234,8 +263,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/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc index 9261e8a65a316..74f775600221f 100644 --- a/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc @@ -25,11 +25,15 @@ include::{includes}/prerequisites.adoc[] == Architecture -In this example, we build a very simple application which offers a single land page: +In this example, we build a very simple application which supports two resource methods: * `/{tenant}` -The land page is served by a JAX-RS Resource and shows information obtained from the OpenID Provider about the authenticated user and the current tenant. +This resource returns information obtained from the ID token issued by OpenID Provider about the authenticated user and the current tenant. + +* `/{tenant}`/bearer + +This resource returns information obtained from the Access token issued by OpenID Provider about the authenticated user and the current tenant. == Solution @@ -90,35 +94,147 @@ import io.quarkus.oidc.IdToken; @Path("/{tenant}") public class HomeResource { - /** - * Injection point for the ID Token issued by the OpenID Connect Provider + * Injection point for the ID Token issued by the OpenID Connect Provider */ @Inject @IdToken JsonWebToken idToken; /** - * Returns the tokens available to the application. This endpoint exists only for demonstration purposes, you should not - * expose these tokens in a real application. - * - * @return the landing page HTML + * Injection point for the Access Token issued by the OpenID Connect Provider + */ + @Inject + JsonWebToken accessToken; + + /** + * Returns the ID Token info. This endpoint exists only for demonstration purposes, you should not + * expose this token in a real application. + * + * @return ID Token info */ @GET @Produces("text/html") - public String getHome() { - StringBuilder response = new StringBuilder().append("").append(""); - + public String getIdTokenInfo() { + StringBuilder response = new StringBuilder().append("") + .append(""); + response.append("

Welcome, ").append(this.idToken.getClaim("email").toString()).append("

\n"); response.append("

You are accessing the application within tenant ").append(idToken.getIssuer()).append(" boundaries

"); - + + return response.append("").append("").toString(); + } + + /** + * Returns the Access Token info. This endpoint exists only for demonstration purposes, you should not + * expose this token in a real application. + * + * @return Access Token info + */ + @GET + @Produces("text/html") + @Path("bearer") + public String getAccessTokenInfo() { + StringBuilder response = new StringBuilder().append("") + .append(""); + + response.append("

Welcome, ").append(this.accessToken.getClaim("email").toString()).append("

\n"); + response.append("

You are accessing the application within tenant ").append(accessToken.getIssuer()).append(" boundaries

"); + return response.append("").append("").toString(); } } ---- -In order to resolve the tenant from incoming requests and map it to a specific `quarkus-oidc` tenant configuration in application.properties, you need to create an implementation for the `io.quarkus.oidc.TenantResolver` interface. +In order to resolve the tenant from incoming requests and map it to a specific `quarkus-oidc` tenant configuration in application.properties, you need to create an implementation for the `io.quarkus.oidc.TenantConfigResolver` interface which can be used to resolve the tenant configurations dynamically: + +[source,java] +---- +package org.acme.quickstart.oidc; + +import javax.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.config.ConfigProvider; + +import io.quarkus.oidc.OidcRequestContext; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.OidcTenantConfig.ApplicationType; +import io.quarkus.oidc.TenantConfigResolver; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomTenantResolver implements TenantConfigResolver { + + @Override + public Uni resolve(RoutingContext context, OidcRequestContext requestContext) { + String path = context.request().path(); + + if (path.startsWith("/tenant-a")) { + String keycloakUrl = ConfigProvider.getConfig().getValue("keycloak.url", String.class); + + OidcTenantConfig config = new OidcTenantConfig(); + config.setTenantId("tenant-a"); + config.setAuthServerUrl(keycloakUrl + "/realms/tenant-a"); + config.setClientId("multi-tenant-client"); + config.getCredentials().setSecret("secret"); + config.setApplicationType(ApplicationType.HYBRID); + return Uni.createFrom().item(config); + } else { + // resolve to default tenant config + return Uni.createFrom().nullItem(); + } + } +} +---- + +From the implementation above, tenants are resolved from the request path so that in case no tenant could be inferred, `null` is returned to indicate that the default tenant configuration should be used. + +Note the `tenant-a` application type is `hybrid` - it can accept HTTP bearer tokens if provided, otherwise it will initiate an authorization code flow when the authentication is required. + +== Configuring the application + +[source,properties] +---- +# Default Tenant Configuration +%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus +quarkus.oidc.client-id=multi-tenant-client +quarkus.oidc.application-type=web-app + +# Tenant A Configuration is created dynamically in CustomTenantConfigResolver + +# HTTP Security Configuration +quarkus.http.auth.permission.authenticated.paths=/* +quarkus.http.auth.permission.authenticated.policy=authenticated +---- + +The first configuration is the default tenant configuration that should be used when the tenant can not be inferred from the request. Note that a `%prod` prodile prefix is used with `quarkus.oidc.auth-server-url` - it is done to support testing a multi-tenant application with `Dev Services For Keycloak`. This configuration is using a Keycloak instance to authenticate users. + +The second configuration is provided by `TenantConfigResolver`, it is the configuration that will be used when an incoming request is mapped to the tenant `tenant-a`. + +Note that both configurations map to the same Keycloak server instance while using distinct `realms`. + +Alternatively you can configure the tenant `tenant-a` directly in `application.properties`: + +[source,properties] +---- +# Default Tenant Configuration +%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus +quarkus.oidc.client-id=multi-tenant-client +quarkus.oidc.application-type=web-app + +# Tenant A Configuration +quarkus.oidc.tenant-a.auth-server-url=http://localhost:8180/realms/tenant-a +quarkus.oidc.tenant-a.client-id=multi-tenant-client +quarkus.oidc.tenant-a.application-type=web-app + +# HTTP Security Configuration +quarkus.http.auth.permission.authenticated.paths=/* +quarkus.http.auth.permission.authenticated.policy=authenticated +---- + +and use a custom `TenantConfigResolver` to resolve it: [source,java] ---- @@ -147,7 +263,9 @@ public class CustomTenantResolver implements TenantResolver { } ---- -From the implementation above, tenants are resolved from the request path so that in case no tenant could be inferred, `null` is returned to indicate that the default tenant configuration should be used. +You can define multiple tenants in your configuration file, just make sure they have a unique alias so that you can map them properly when resolving a tenant from your `TenantResolver` implementation. + +However, using a static tenant resolution (configuring tenants in `application.properties` and resolving them with `TenantResolver`) prevents testing the endpoint with `Dev Services for Keycloak` since `Dev Services for Keycloak` has no knowledge of how the requests will be mapped to individual tenants and can not dynamically provide tenant-specific `quarkus.oidc..auth-server-url` values and therefore using `%prod` prefixes with the tenant-specific URLs in `application.properties` will not work in tests or devmode. [NOTE] ==== @@ -185,6 +303,8 @@ public class CustomTenantResolver implements TenantResolver { } } ---- + +A similar technique can be used with `TenantConfigResolver` where a `tenant-id` provided in the context can be used to return `OidcTenantConfig` already prepared with the previous request. ==== [NOTE] @@ -205,50 +325,6 @@ public class CustomTenantResolver implements TenantResolver { ---- ==== -== Configuring the application - -[source,properties] ----- -# Default Tenant Configuration -quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus -quarkus.oidc.client-id=multi-tenant-client -quarkus.oidc.application-type=web-app - -# Tenant A Configuration -quarkus.oidc.tenant-a.auth-server-url=http://localhost:8180/realms/tenant-a -quarkus.oidc.tenant-a.client-id=multi-tenant-client -quarkus.oidc.tenant-a.application-type=web-app - -# HTTP Security Configuration -quarkus.http.auth.permission.authenticated.paths=/* -quarkus.http.auth.permission.authenticated.policy=authenticated ----- - -The first configuration is the default tenant configuration that should be used when the tenant can not be inferred from the request. This configuration is using a Keycloak instance to authenticate users. - -The second configuration is the configuration that will be used when an incoming request is mapped to the tenant `tenant-a`. - -Note that both configurations map to the same Keycloak server instance while using distinct `realms`. - -You can define multiple tenants in your configuration file, just make sure they have a unique alias so that you can map them properly when resolving a tenant from your `TenantResolver` implementation. - -=== Google OpenID Provider Configuration - -In order to set up the `tenant-a` configuration to use Google OpenID Provider, you need to create a project as described https://developers.google.com/identity/protocols/OpenIDConnect[here]. - -Once you create the project and have your project's `client_id` and `client_secret`, you can try to configure a tenant as follows: - -[source, properties] ----- -# Tenant configuration using Google OpenID Provider -quarkus.oidc.tenant-b.auth-server-url=https://accounts.google.com -quarkus.oidc.tenant-b.application-type=web-app -quarkus.oidc.tenant-b.client-id={GOOGLE_CLIENT_ID} -quarkus.oidc.tenant-b.credentials.secret={GOOGLE_CLIENT_SECRET} -quarkus.oidc.tenant-b.token.issuer=https://accounts.google.com -quarkus.oidc.tenant-b.authentication.scopes=email,profile,openid ----- - == Starting and Configuring the Keycloak Server To start a Keycloak Server you can use Docker and just run the following command: @@ -314,7 +390,163 @@ After getting a cup of coffee, you'll be able to run this binary directly: ./target/security-openid-connect-multi-tenancy-quickstart-runner ---- -== Testing the Application +== Test the Application + +=== Use Dev Services for Keycloak + +Using xref:security-openid-connect-dev-services.adoc[Dev Services for Keycloak] is recommended for the integration testing against Keycloak. +`Dev Services for Keycloak` will launch and initialize a test container: it will import configured realms and set a base Keycloak URL for `CustomTenantResolver` used in this quickstart to calculate a realm specific URL. + + +First you need to add the following dependencies: + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.quarkus + quarkus-test-keycloak-server + test + + + io.rest-assured + rest-assured + test + + + net.sourceforge.htmlunit + htmlunit + test + +---- + +[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] +.build.gradle +---- +testImplementation("io.quarkus:quarkus-test-keycloak-server") +testImplementation("io.rest-assured:rest-assured") +testImplementation("net.sourceforge.htmlunit:htmlunit") +---- + +`quarkus-test-keycloak-server` provides a utility class `io.quarkus.test.keycloak.client.KeycloakTestClient` for acquiring the realm specific access tokens and which you can use with `RestAssured` for testing the `/{tenant}/bearer` endpoint expecting bearer access tokens. +`HtmlUnit` is used for testing the `/{tenant}` endpoint and the authorization code flow. + +Next, configure the required realms: + +[source,properties] +---- +# Default Tenant Configuration +%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus +quarkus.oidc.client-id=multi-tenant-client +quarkus.oidc.application-type=web-app + +# Tenant A Configuration is created dynamically in CustomTenantConfigResolver + +# HTTP Security Configuration +quarkus.http.auth.permission.authenticated.paths=/* +quarkus.http.auth.permission.authenticated.policy=authenticated + +quarkus.keycloak.devservices.realm-path=default-tenant-realm.json,tenant-a-realm.json +---- + +Finally, write your test which will be executed in JVM mode: + +[source,java] +---- +package org.acme.quickstart.oidc; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.keycloak.client.KeycloakTestClient; +import io.restassured.RestAssured; + +@QuarkusTest +public class CodeFlowTest { + + KeycloakTestClient keycloakClient = new KeycloakTestClient(); + + @Test + public void testLogInDefaultTenant() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/default"); + + assertEquals("Sign in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + + assertTrue(page.asText().contains("tenant")); + } + } + + @Test + public void testLogInTenantAWebApp() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/tenant-a"); + + assertEquals("Sign in to tenant-a", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + + assertTrue(page.asText().contains("alice@tenant-a.org")); + } + } + + @Test + public void testLogInTenantABearerToken() throws IOException { + RestAssured.given().auth().oauth2(getAccessToken()).when() + .get("/tenant-a/bearer").then().body(containsString("alice@tenant-a.org")); + } + + private String getAccessToken() { + return keycloakClient.getRealmAccessToken("tenant-a", "alice", "alice", "multi-tenant-client", "secret"); + } + + private WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } +} +---- + +and in native mode: + +[source,java] +---- +package org.acme.quickstart.oidc; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class CodeFlowIT extends CodeFlowTest { +} +---- + +Please see xref:security-openid-connect-dev-services.adoc[Dev Services for Keycloak] for more information about the way it is initialized and configured. + +=== Use Browser To test the application, you should open your browser and access the following URL: 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 08f34baaf2e6f..cd580975597c5 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 5047e5f2b8160..38b17975a97f1 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 31f90109de255..a37d73eb4bef1 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; @@ -100,7 +100,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 +110,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. @@ -125,7 +124,7 @@ public class KeycloakDevServicesProcessor { private static volatile RunningDevService devService; static volatile DevServicesConfig capturedDevServicesConfiguration; private static volatile boolean first = true; - private static volatile FileTime capturedRealmFileLastModifiedDate; + private static volatile Set capturedRealmFileLastModifiedDate; OidcBuildTimeConfig oidcConfig; @@ -153,7 +152,7 @@ public DevServicesResultBuildItem startKeycloakContainer( if (devService != null) { boolean restartRequired = !currentDevServicesConfiguration.equals(capturedDevServicesConfiguration); if (!restartRequired) { - FileTime currentRealmFileLastModifiedDate = getRealmFileLastModifiedDate( + Set currentRealmFileLastModifiedDate = getRealmFileLastModifiedDate( currentDevServicesConfiguration.realmPath); if (currentRealmFileLastModifiedDate != null && !currentRealmFileLastModifiedDate.equals(capturedRealmFileLastModifiedDate)) { @@ -167,8 +166,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 { @@ -254,29 +257,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, List errors) { - 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, - errors); - } else if (realmRep != null && keycloakX) { - createRealm(client, adminToken, clientAuthServerBaseUrl, realmRep, errors); + createDefaultRealm(client, adminToken, clientAuthServerBaseUrl, users, oidcClientId, oidcClientSecret, errors); + realmNames.add(realmName); + } else { + for (RealmRepresentation realmRep : realmReps) { + createRealm(client, adminToken, clientAuthServerBaseUrl, realmRep, errors); + realmNames.add(realmRep.getRealm()); + } } } finally { client.close(); @@ -291,9 +300,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; } @@ -346,7 +357,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, @@ -365,7 +376,7 @@ private RunningDevService startContainer(DockerStatusBuildItem dockerStatusBuild : null; Map configs = prepareConfiguration(keycloakBuildItemBuildProducer, internalUrl, hostUrl, - oidcContainer.realmRep, + oidcContainer.realmReps, oidcContainer.keycloakX, errors); return new RunningDevService(KEYCLOAK_CONTAINER_NAME, oidcContainer.getContainerId(), @@ -397,25 +408,25 @@ 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; private final List errors; public QuarkusOidcContainer(DockerImageName dockerImageName, OptionalInt fixedExposedPort, boolean useSharedNetwork, - Optional realmPath, String containerLabelValue, + List realmPaths, String containerLabelValue, boolean sharedContainer, Optional javaOpts, Optional startCommand, boolean showLogs, List errors) { super(dockerImageName); this.useSharedNetwork = useSharedNetwork; - this.realmPath = realmPath; + this.realmPaths = realmPaths; this.containerLabelValue = containerLabelValue; this.sharedContainer = sharedContainer; this.javaOpts = javaOpts; @@ -479,33 +490,22 @@ 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(), errors); - if (!keycloakX) { - withClasspathResourceMapping(realmPath.get(), KEYCLOAK_DOCKER_REALM_PATH, BindMode.READ_ONLY); - } + if ((realmPathUrl = Thread.currentThread().getContextClassLoader().getResource(realmPath)) != null) { + readRealmFile(realmPathUrl, realmPath, errors).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(), errors); + readRealmFile(filePath.toUri(), realmPath, errors).ifPresent(realmRep -> realmReps.add(realmRep)); } else { - errors.add(String.format("Realm %s resource is not available", realmPath.get())); - - LOG.errorf("Realm %s resource is not available", realmPath.get()); + errors.add(String.format("Realm %s resource is not available", realmPath)); + 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()); @@ -523,7 +523,7 @@ private Integer findRandomPort() { } } - private RealmRepresentation readRealmFile(URI uri, String realmPath, List errors) { + private Optional readRealmFile(URI uri, String realmPath, List errors) { try { return readRealmFile(uri.toURL(), realmPath, errors); } catch (MalformedURLException ex) { @@ -532,17 +532,17 @@ private RealmRepresentation readRealmFile(URI uri, String realmPath, List errors) { + private Optional readRealmFile(URL url, String realmPath, List errors) { try { try (InputStream is = url.openStream()) { - return JsonSerialization.readValue(is, RealmRepresentation.class); + return Optional.of(JsonSerialization.readValue(is, RealmRepresentation.class)); } } catch (IOException ex) { errors.add(String.format("Realm %s resource can not be opened: %s", realmPath, ex.getMessage())); LOG.errorf("Realm %s resource can not be opened: %s", realmPath, ex.getMessage()); } - return null; + return Optional.empty(); } @Override @@ -582,14 +582,20 @@ public int getPort() { } } - private FileTime getRealmFileLastModifiedDate(Optional realm) { - if (realm.isPresent()) { - Path realmPath = Paths.get(realm.get()); - try { - return Files.getLastModifiedTime(realmPath); - } catch (IOException ex) { - LOG.tracef("Unable to get the last modified date of the realm file %s", realmPath); + private Set getRealmFileLastModifiedDate(Optional> realms) { + if (realms.isPresent()) { + Set times = new HashSet<>(); + + for (String realm : realms.get()) { + Path realmPath = Paths.get(realm); + try { + times.add(Files.getLastModifiedTime(realmPath)); + } catch (IOException ex) { + LOG.tracef("Unable to get the last modified date of the realm file %s", realmPath); + } } + + return times; } return null; } @@ -645,6 +651,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()); @@ -777,11 +784,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 a18c17d2d3656..efd7382381617 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 13335c8a8dd2b..b052998ecb909 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")