diff --git a/.github/renovate.json b/.github/renovate.json index 8c4946738c..f35acd8733 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -81,7 +81,14 @@ "matchStrings": ["ARG DOCKER_VERSION=(?.*?)\n"], "depNameTemplate": "docker", "datasourceTemplate": "docker" + }, + { + "fileMatch": ["src/test/java/plugins/OicAuthPluginTest.java"], + "matchStrings": [".* KEYCLOAK_IMAGE =\n\\s*\"(?.*?):(?.*?)@(?sha256:.*?)\";\n"], + "depNameTemplate": "{{{repo}}}", + "datasourceTemplate": "docker" } + ], "customDatasources": { "firefox": { diff --git a/docs/BROWSER.md b/docs/BROWSER.md index ec543af118..2fde14429c 100644 --- a/docs/BROWSER.md +++ b/docs/BROWSER.md @@ -69,6 +69,8 @@ If the host running maven is different to the host running Selenium (e.g. `remot If this is the case you can specify the address to use using: `SELENIUM_PROXY_HOSTNAME=ip.address.of.host mvn install` **Important**: this could exposed the proxy wider beyond your machine and expose other internal services, so this should only be used on private or internal networks to prevent any information leak. +The same issue will also impact any other containers started that the tests that the Browser (rather than Jenkins) needs to access. +For [Testcontainers](https://testcontainers.com/) you can additionally set `TESTCONTAINERS_HOST_OVERRIDE=ip.address.of.host` ## Avoid focus steal with Xvnc on Linux If you select a real GUI browser, such as Firefox, diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 4e53f8379b..d845f1f546 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -71,3 +71,10 @@ It's best explained with an example: # Using a custom docker network If you are using a custom network for the container that executes the testing you may instruct the docker-fixtures to use the same one by setting the env variable `DOCKER_FIXTURES_NETWORK`to specify the network you want your fixtures to connect to. + +# Using Testcontainers with a remote webdriver + +If you are using a containerized webdriver (or any other remote webdriver) then any containers launched will not be reachable from the remote web browser. +This will be an issue for some tests that require the browser interact with the container (e.g. for authentication). +If this is the case then the `TESTCONTAINERS_HOST_OVERRIDE` should be set to `host.docker.internal` or if the remote browser is non local the IP adddress of your machine. + diff --git a/pom.xml b/pom.xml index 9d51e6fe59..9462d5c135 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,16 @@ commons-io 2.16.1 + + + jakarta.ws.rs + jakarta.ws.rs-api + 3.1.0 + net.bytebuddy @@ -212,6 +222,11 @@ and commons-net 3.11.1 + + jakarta.xml.bind + jakarta.xml.bind-api + 3.0.1 + junit junit @@ -364,6 +379,23 @@ and zt-zip 1.17 + + + com.github.dasniko + testcontainers-keycloak + 3.4.0 + test + + + jakarta.annotation + jakarta.annotation-api + 2.1.1 + test + jakarta.mail jakarta.mail-api diff --git a/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java new file mode 100644 index 0000000000..091c1ab7d9 --- /dev/null +++ b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java @@ -0,0 +1,45 @@ +package org.jenkinsci.test.acceptance.po; + +/** + * Security Realm provided by oic-auth plugin + */ +@Describable("Login with Openid Connect") +public class OicAuthSecurityRealm extends SecurityRealm { + + public OicAuthSecurityRealm(GlobalSecurityConfig context, String path) { + super(context, path); + } + + public void configureClient(String clientId, String clientSecret) { + control("clientId").set(clientId); + control("clientSecret").set(clientSecret); + } + + public void setAutomaticConfiguration(String wellKnownEndpoint) { + control(by.radioButton("Automatic configuration")).click(); + control("wellKnownOpenIDConfigurationUrl").set(wellKnownEndpoint); + } + + public void setLogoutFromOpenidProvider(boolean logout) { + Control check = control(by.checkbox("Logout from OpenID Provider")); + if (logout) { + check.check(); + } else { + check.uncheck(); + } + } + + public void setPostLogoutUrl(String postLogoutUrl) { + control("postLogoutRedirectUrl").set(postLogoutUrl); + } + + public void setUserFields( + String userNameFieldName, String emailFieldName, String fullNameFieldName, String groupsFieldName) { + clickButton("User fields"); + waitFor(by.path("/securityRealm/groupsFieldName")); + control("userNameField").set(userNameFieldName); + control("emailFieldName").set(emailFieldName); + control("fullNameFieldName").set(fullNameFieldName); + control("groupsFieldName").set(groupsFieldName); + } +} diff --git a/src/main/java/org/jenkinsci/test/acceptance/po/WhoAmI.java b/src/main/java/org/jenkinsci/test/acceptance/po/WhoAmI.java new file mode 100644 index 0000000000..eb89296a63 --- /dev/null +++ b/src/main/java/org/jenkinsci/test/acceptance/po/WhoAmI.java @@ -0,0 +1,11 @@ +package org.jenkinsci.test.acceptance.po; + +/** + * Who Am I page in Jenkins + */ +public class WhoAmI extends ContainerPageObject { + + public WhoAmI(ContainerPageObject parent) { + super(parent, parent.url("whoAmI/")); + } +} diff --git a/src/main/java/org/jenkinsci/test/acceptance/utils/keycloack/KeycloakUtils.java b/src/main/java/org/jenkinsci/test/acceptance/utils/keycloack/KeycloakUtils.java new file mode 100644 index 0000000000..f6b22a5fbc --- /dev/null +++ b/src/main/java/org/jenkinsci/test/acceptance/utils/keycloack/KeycloakUtils.java @@ -0,0 +1,92 @@ +package org.jenkinsci.test.acceptance.utils.keycloack; + +import jakarta.inject.Inject; +import java.net.URL; +import org.jenkinsci.test.acceptance.po.CapybaraPortingLayerImpl; +import org.jenkinsci.test.acceptance.utils.ElasticTime; +import org.openqa.selenium.WebDriver; + +public class KeycloakUtils extends CapybaraPortingLayerImpl { + + @Inject + public WebDriver driver; + + @Inject + public ElasticTime time; + + public KeycloakUtils() { + super(null); + } + + public void open(URL url) { + visit(url); + } + + public void login(String user) { + login(user, user); + } + + public void login(String user, String passwd) { + waitFor(by.id("username"), 5); + find(by.id("username")).sendKeys(user); + find(by.id("password")).sendKeys(passwd); + find(by.id("kc-login")).click(); + } + + public User getCurrentUser(String keycloakUrl, String realm) { + driver.get(String.format("%s/realms/%s/account", keycloakUrl, realm)); + + waitFor(by.id("username"), 5); + String username = find(by.id("username")).getDomProperty("value"); + String email = find(by.id("email")).getDomProperty("value"); + String firstName = find(by.id("firstName")).getDomProperty("value"); + String lastName = find(by.id("lastName")).getDomProperty("value"); + + return new User(null /* id not available in this page*/, username, email, firstName, lastName); + } + + public void logout(User user) { + final String caption = user.getFirstName() + " " + user.getLastName(); + waitFor(by.button(caption), 5); + clickButton(caption); + waitFor(by.button("Sign out")); + clickButton("Sign out"); + } + + public static class User { + + private final String id; + private final String userName; + private final String email; + private final String firstName; + private final String lastName; + + public User(String id, String userName, String email, String firstName, String lastName) { + this.id = id; + this.userName = userName; + this.email = email; + this.firstName = firstName; + this.lastName = lastName; + } + + public String getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getEmail() { + return email; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + } +} diff --git a/src/test/java/plugins/OicAuthPluginTest.java b/src/test/java/plugins/OicAuthPluginTest.java new file mode 100644 index 0000000000..f03eb0f648 --- /dev/null +++ b/src/test/java/plugins/OicAuthPluginTest.java @@ -0,0 +1,310 @@ +package plugins; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.in; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import jakarta.inject.Inject; +import java.net.URL; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.jenkinsci.test.acceptance.junit.AbstractJUnitTest; +import org.jenkinsci.test.acceptance.junit.WithPlugins; +import org.jenkinsci.test.acceptance.po.GlobalSecurityConfig; +import org.jenkinsci.test.acceptance.po.LoggedInAuthorizationStrategy; +import org.jenkinsci.test.acceptance.po.OicAuthSecurityRealm; +import org.jenkinsci.test.acceptance.po.WhoAmI; +import org.jenkinsci.test.acceptance.utils.keycloack.KeycloakUtils; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.GroupResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.openqa.selenium.NoSuchElementException; + +@WithPlugins("oic-auth") +public class OicAuthPluginTest extends AbstractJUnitTest { + + private static final String REALM = "test-realm"; + private static final String CLIENT = "jenkins"; + + private static final String KEYCLOAK_IMAGE = + "keycloak/keycloak:25.0.4@sha256:bf788a3b7fd737143f98d4cb514cb9599c896acee01a26b2117a10bd99e23e11"; + + @Rule + public KeycloakContainer keycloak = new KeycloakContainer(KEYCLOAK_IMAGE); + + @Inject + public KeycloakUtils keycloakUtils; + + private String userBobKeycloakId; + private String userJohnKeycloakId; + + @Before + public void setUpKeycloak() throws Exception { + configureOIDCProvider(); + configureRealm(); + } + + private void configureOIDCProvider() throws Exception { + try (Keycloak keycloakAdmin = keycloak.getKeycloakAdminClient()) { + // Create Realm + RealmRepresentation testRealm = new RealmRepresentation(); + testRealm.setRealm(REALM); + testRealm.setId(REALM); + testRealm.setDisplayName(REALM); + testRealm.setEnabled(true); + + keycloakAdmin.realms().create(testRealm); + RoleRepresentation jenkinsRead = new RoleRepresentation(); + jenkinsRead.setName("jenkinsRead"); + keycloakAdmin.realm(REALM).roles().create(jenkinsRead); + RoleRepresentation jenkinsAdmin = new RoleRepresentation(); + jenkinsAdmin.setName("jenkinsAdmin"); + keycloakAdmin.realm(REALM).roles().create(jenkinsAdmin); + + // Add groups and subgroups + GroupRepresentation employees = new GroupRepresentation(); + employees.setName("employees"); + + final RealmResource theRealm = keycloakAdmin.realm(REALM); + theRealm.groups().add(employees); + + String groupId = theRealm.groups().groups().get(0).getId(); + + GroupRepresentation devs = new GroupRepresentation(); + devs.setName("devs"); + GroupResource group = theRealm.groups().group(groupId); + group.subGroup(devs); + + GroupRepresentation sales = new GroupRepresentation(); + sales.setName("sales"); + group = theRealm.groups().group(groupId); + group.subGroup(sales); + + List subGroups = + theRealm.groups().group(groupId).getSubGroups(0, 2, true); + String devsId = subGroups.stream() + .filter(g -> g.getName().equals("devs")) + .findFirst() + .orElseThrow(() -> new Exception("Something went wrong initialization keycloak")) + .getId(); + String salesId = subGroups.stream() + .filter(g -> g.getName().equals("sales")) + .findFirst() + .orElseThrow(() -> new Exception("Something went wrong initialization keycloak")) + .getId(); + theRealm.groups() + .group(devsId) + .roles() + .realmLevel() + .add(List.of(theRealm.roles().get("jenkinsAdmin").toRepresentation())); + theRealm.groups() + .group(salesId) + .roles() + .realmLevel() + .add(List.of(theRealm.roles().get("jenkinsRead").toRepresentation())); + + // Users + UserRepresentation bob = new UserRepresentation(); + bob.setEmail("bob@jenkins-ath.test"); + bob.setUsername("bob"); + bob.setFirstName("Bob"); + bob.setLastName("Smith"); + CredentialRepresentation credentials = new CredentialRepresentation(); + credentials.setValue("bob"); + credentials.setTemporary(false); + credentials.setType(CredentialRepresentation.PASSWORD); + bob.setCredentials(List.of(credentials)); + bob.setGroups(Arrays.asList("/employees", "/employees/devs")); + bob.setEmailVerified(true); + bob.setEnabled(true); + theRealm.users().create(bob); + + UserRepresentation john = new UserRepresentation(); + john.setEmail("john@jenkins-ath.test"); + john.setUsername("john"); + john.setFirstName("John"); + john.setLastName("Smith"); + credentials = new CredentialRepresentation(); + credentials.setValue("john"); + credentials.setTemporary(false); + credentials.setType(CredentialRepresentation.PASSWORD); + john.setCredentials(List.of(credentials)); + john.setGroups(Arrays.asList("/employees", "/employees/sales")); + john.setEmailVerified(true); + john.setEnabled(true); + theRealm.users().create(john); + + // Client + ClientRepresentation jenkinsClient = new ClientRepresentation(); + jenkinsClient.setClientId(CLIENT); + jenkinsClient.setProtocol("openid-connect"); + jenkinsClient.setSecret(CLIENT); + final String jenkinsUrl = jenkins.url.toString(); + jenkinsClient.setRootUrl(jenkinsUrl); + jenkinsClient.setRedirectUris(List.of(String.format("%ssecurityRealm/finishLogin", jenkinsUrl))); + jenkinsClient.setWebOrigins(List.of(jenkinsUrl)); + jenkinsClient.setAttributes(Map.of("post.logout.redirect.uris", String.format("%sOicLogout", jenkinsUrl))); + theRealm.clients().create(jenkinsClient); + + // Assert that the realm is properly created + assertThat("group is created", theRealm.groups().groups().get(0).getName(), is("employees")); + GroupResource g = theRealm.groups().group(groupId); + assertThat( + "subgroups are created", + g.getSubGroups(0, 2, true).stream() + .map(GroupRepresentation::getName) + .collect(Collectors.toList()), + containsInAnyOrder("devs", "sales")); + assertThat( + "users are created", + theRealm.users().list().stream() + .map(UserRepresentation::getUsername) + .collect(Collectors.toList()), + containsInAnyOrder("bob", "john")); + userBobKeycloakId = + theRealm.users().searchByUsername("bob", true).get(0).getId(); + assertThat( + "User bob with the correct groups", + theRealm.users().get(userBobKeycloakId).groups().stream() + .map(GroupRepresentation::getPath) + .collect(Collectors.toList()), + containsInAnyOrder("/employees", "/employees/devs")); + userJohnKeycloakId = + theRealm.users().searchByUsername("john", true).get(0).getId(); + assertThat( + "User john with the correct groups", + theRealm.users().get(userJohnKeycloakId).groups().stream() + .map(GroupRepresentation::getPath) + .collect(Collectors.toList()), + containsInAnyOrder("/employees", "/employees/sales")); + assertThat( + "client is created", + theRealm.clients().findByClientId(CLIENT).get(0).getProtocol(), + is("openid-connect")); + } + } + + private void configureRealm() { + final String keycloakUrl = keycloak.getAuthServerUrl(); + GlobalSecurityConfig sc = new GlobalSecurityConfig(jenkins); + sc.open(); + OicAuthSecurityRealm securityRealm = sc.useRealm(OicAuthSecurityRealm.class); + securityRealm.configureClient(CLIENT, CLIENT); + securityRealm.setAutomaticConfiguration( + String.format("%s/realms/%s/.well-known/openid-configuration", keycloakUrl, REALM)); + securityRealm.setLogoutFromOpenidProvider(true); + securityRealm.setPostLogoutUrl(jenkins.url("OicLogout").toExternalForm()); + securityRealm.setUserFields(null, null, null, "groups"); + sc.useAuthorizationStrategy(LoggedInAuthorizationStrategy.class); + sc.save(); + } + + /** + * Test the login and logout in Jenkins and how it is propagated to the OIDC Provider + * - A login in Jenkins will redirect to the provider login page, so the user will be logged in as well in keycloak + * - A logout in Jenkins will apply only to Jenkins or will be propagated to the provider (keycloak here) depending + * on the value of the property Lout from openid provider in the Security Realm configuration. For this test, + * it'll be propagated + */ + @Test + public void loginAndLogoutInJenkinsIsReflectedInKeycloak() { + final KeycloakUtils.User bob = + new KeycloakUtils.User(userBobKeycloakId, "bob", "bob@jenkins-ath.test", "Bob", "Smith"); + final KeycloakUtils.User john = + new KeycloakUtils.User(userJohnKeycloakId, "john", "john@jenkins-ath.test", "John", "Smith"); + jenkins.open(); + + jenkins.clickLink("log in"); + keycloakUtils.login(bob.getUserName()); + assertLoggedUser(bob, "jenkinsAdmin"); + + jenkins.logout(); + jenkins.open(); + assertLoggedOut(); + + // As the option Logout from Openid Provider is set to true, a logout from Jenkins means a logout from Keycloak + // so the login page will appear again + jenkins.open(); + + clickLink("log in"); + keycloakUtils.login(john.getUserName()); + assertLoggedUser(john, "jenkinsRead"); + } + + /** + * Test the login and logout in the OIDC provider and how it is propagated to Jenkins + * - A login in the provider will be detected by the plugin so Jenkins won't prompt the login form + * - A logout in the provider will redirect to the Jenkins logout action (configured in keycloak) so the user + * will log out from Jenkins as well + */ + @Test + public void loginAndLogoutInKeycloakIsReflectedInJenkins() throws Exception { + final KeycloakUtils.User bob = + new KeycloakUtils.User(userBobKeycloakId, "bob", "bob@jenkins-ath.test", "Bob", "Smith"); + final KeycloakUtils.User john = + new KeycloakUtils.User(userJohnKeycloakId, "john", "john@jenkins-ath.test", "John", "Smith"); + final String loginUrl = String.format("%s/realms/%s/account", keycloak.getAuthServerUrl(), REALM); + keycloakUtils.open(new URL(loginUrl)); + + keycloakUtils.login(bob.getUserName()); + jenkins.open(); + jenkins.clickLink("log in"); // won't request a login, but log in directly with user from + + assertLoggedUser(bob, "jenkinsAdmin"); + + keycloakUtils.logout(bob); + jenkins.open(); + jenkins.logout(); // logout from keycloak does not logout from Jenkins (seems not supported in the plugin) + assertLoggedOut(); + + // Once logged out, we can change the user + jenkins.open(); + jenkins.clickLink("log in"); + keycloakUtils.login(john.getUserName()); + assertLoggedUser(john, "jenkinsRead"); + } + + private void assertLoggedOut() { + assertNull("User has logged out from Jenkins", jenkins.getCurrentUser().id()); + + assertThrows( + "User has logged out from keycloak", + NoSuchElementException.class, + () -> keycloakUtils.getCurrentUser(keycloak.getAuthServerUrl(), REALM)); + } + + private void assertLoggedUser(KeycloakUtils.User expectedUser, String roleToCheck) { + assertThat("User has logged in Jenkins", jenkins.getCurrentUser().id(), is(expectedUser.getId())); + + WhoAmI whoAmI = new WhoAmI(jenkins); + whoAmI.open(); + JSONObject jsonData = new JSONObject(whoAmI.getJson().toString()); + assertThat("User is the expected one", jsonData.getString("name"), is(expectedUser.getId())); + assertThat( + "User has the expected roles inherited from keycloak", + roleToCheck, + is(in(jsonData.getJSONArray("authorities").toList()))); + + KeycloakUtils.User fromKeyCloak = keycloakUtils.getCurrentUser(keycloak.getAuthServerUrl(), REALM); + assertThat("User has logged in keycloack", fromKeyCloak.getUserName(), is(expectedUser.getUserName())); + assertThat("User has logged in keycloack", fromKeyCloak.getEmail(), is(expectedUser.getEmail())); + assertThat("User has logged in keycloack", fromKeyCloak.getFirstName(), is(expectedUser.getFirstName())); + assertThat("User has logged in keycloack", fromKeyCloak.getLastName(), is(expectedUser.getLastName())); + } +} diff --git a/vars.cmd b/vars.cmd index 501ff32526..c463ffaf03 100644 --- a/vars.cmd +++ b/vars.cmd @@ -23,6 +23,7 @@ IF NOT DEFINED IP ( @echo on set SELENIUM_PROXY_HOSTNAME=%IP% +set TESTCONTAINERS_HOST_OVERRIDE=%IP% set JENKINS_LOCAL_HOSTNAME=%IP% @echo. @echo To start the remote firefox container run the following command: