From 66f2942eea748f83a1a10048244a7905df5e0cf6 Mon Sep 17 00:00:00 2001 From: Francisco Javier Fernandez Gonzalez Date: Fri, 30 Aug 2024 12:22:55 +0200 Subject: [PATCH 1/9] Add test for oic-auth plugin --- pom.xml | 32 +++ .../acceptance/po/OicAuthSecurityRealm.java | 35 +++ .../utils/keycloack/KeycloakUtils.java | 95 ++++++++ src/test/java/plugins/OicAuthPluginTest.java | 227 ++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java create mode 100644 src/main/java/org/jenkinsci/test/acceptance/utils/keycloack/KeycloakUtils.java create mode 100644 src/test/java/plugins/OicAuthPluginTest.java diff --git a/pom.xml b/pom.xml index fb90bf63aa..d950387ea8 100644 --- a/pom.xml +++ b/pom.xml @@ -314,6 +314,28 @@ 2.1.3 test + + + com.github.dasniko + testcontainers-keycloak + 3.4.0 + test + + + jakarta.annotation + jakarta.annotation-api + 2.1.1 + test + + + jakarta.xml.bind + jakarta.xml.bind-api + 3.0.1 + @@ -386,6 +408,16 @@ and httpcore 4.4.16 + + + jakarta.ws.rs + jakarta.ws.rs-api + 3.1.0 + 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..d8aa7e3772 --- /dev/null +++ b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java @@ -0,0 +1,35 @@ +package org.jenkinsci.test.acceptance.po; + +/** + * Security Realm provided by oic-auth plugin + */ +@Describable({"Login with Openid Connect", "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 logoutFromOpenidProvider(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); + } +} 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..1b4f9f6e0a --- /dev/null +++ b/src/main/java/org/jenkinsci/test/acceptance/utils/keycloack/KeycloakUtils.java @@ -0,0 +1,95 @@ +package org.jenkinsci.test.acceptance.utils.keycloack; + +import java.net.URL; + +import org.jenkinsci.test.acceptance.po.CapybaraPortingLayerImpl; +import org.jenkinsci.test.acceptance.utils.ElasticTime; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import jakarta.inject.Inject; + +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 getUser(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..af910dc8bd --- /dev/null +++ b/src/test/java/plugins/OicAuthPluginTest.java @@ -0,0 +1,227 @@ +package plugins; + +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.utils.keycloack.KeycloakUtils; +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.UserRepresentation; +import org.openqa.selenium.NoSuchElementException; +import dasniko.testcontainers.keycloak.KeycloakContainer; +import jakarta.inject.Inject; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +@WithPlugins("oic-auth") +public class OicAuthPluginTest extends AbstractJUnitTest { + + private static final String REALM = "test-realm"; + private static final String CLIENT = "jenkins"; + + @Rule + public KeycloakContainer keycloak = new KeycloakContainer(); + + @Inject + public KeycloakUtils keycloakUtils; + + private String userBobKeycloakId; + private String userJohnKeycloakId; + + @Before + public void setUpKeycloak() { + configureOIDCProvider(); + configureRealm(); + } + + private void configureOIDCProvider() { + try (Keycloak keycloakAdmin = keycloak.getKeycloakAdminClient()) { + RealmRepresentation testRealm = new RealmRepresentation(); + testRealm.setRealm(REALM); + testRealm.setId(REALM); + testRealm.setDisplayName(REALM); + testRealm.setEnabled(true); + + keycloakAdmin.realms().create(testRealm); + + // 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); + + // Users + UserRepresentation bob = new UserRepresentation(); + bob.setEmail("bob@acme.org"); + 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@acme.org"); + 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.logoutFromOpenidProvider(true); + securityRealm.setPostLogoutUrl(jenkins.url("OicLogout").toExternalForm()); + sc.useAuthorizationStrategy(LoggedInAuthorizationStrategy.class); + sc.save(); + } + + @Test + public void fromJenkinsToKeycloak() { + final KeycloakUtils.User bob = new KeycloakUtils.User(userBobKeycloakId, "bob", "bob@acme.org", "Bob", "Smith"); + final KeycloakUtils.User john = new KeycloakUtils.User(userJohnKeycloakId, "john", "john@acme.org", "John", "Smith"); + jenkins.open(); + + jenkins.clickLink("log in"); + keycloakUtils.login(bob.getUserName()); + assertLoggedUser(bob); + + jenkins.logout(); + jenkins.open(); + assertLoggedOut(); + + // logout from Jenkins does mean logout from keycloak + jenkins.open(); + + clickLink("log in"); + keycloakUtils.login(john.getUserName()); + assertLoggedUser(john); + } + + @Test + public void fromKeycloakToJenkins() throws Exception { + final KeycloakUtils.User bob = new KeycloakUtils.User(userBobKeycloakId, "bob", "bob@acme.org", "Bob", "Smith"); + final KeycloakUtils.User john = new KeycloakUtils.User(userJohnKeycloakId, "john", "john@acme.org", "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); + + 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); + } + + private void assertLoggedOut() { + assertNull("User has logged out from Jenkins", jenkins.getCurrentUser().id()); + + assertThrows("User has logged out from keycloak", NoSuchElementException.class, + () -> keycloakUtils.getUser(keycloak.getAuthServerUrl(), REALM)); + } + + private void assertLoggedUser(KeycloakUtils.User expectedUser) { + assertThat("User has logged in Jenkins", jenkins.getCurrentUser().id(), is(expectedUser.getId())); + + KeycloakUtils.User fromKeyCloak = keycloakUtils.getUser(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())); + } + +} From 984124fcc81a2f5edb512792c8c6452ae3266fd2 Mon Sep 17 00:00:00 2001 From: Francisco Javier Fernandez Gonzalez Date: Mon, 2 Sep 2024 10:43:32 +0200 Subject: [PATCH 2/9] Retrieve and check the roles from the OICD provider --- .../acceptance/po/OicAuthSecurityRealm.java | 9 ++++ src/test/java/plugins/OicAuthPluginTest.java | 41 +++++++++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java index d8aa7e3772..37cf588703 100644 --- a/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java +++ b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java @@ -32,4 +32,13 @@ public void logoutFromOpenidProvider(boolean logout) { 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/test/java/plugins/OicAuthPluginTest.java b/src/test/java/plugins/OicAuthPluginTest.java index af910dc8bd..5612bb5a0c 100644 --- a/src/test/java/plugins/OicAuthPluginTest.java +++ b/src/test/java/plugins/OicAuthPluginTest.java @@ -4,8 +4,10 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import org.jenkinsci.test.acceptance.junit.AbstractJUnitTest; import org.jenkinsci.test.acceptance.junit.WithPlugins; import org.jenkinsci.test.acceptance.po.GlobalSecurityConfig; @@ -22,6 +24,7 @@ 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; import dasniko.testcontainers.keycloak.KeycloakContainer; @@ -29,6 +32,7 @@ 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; @@ -49,13 +53,14 @@ public class OicAuthPluginTest extends AbstractJUnitTest { private String userJohnKeycloakId; @Before - public void setUpKeycloak() { + public void setUpKeycloak() throws Exception { configureOIDCProvider(); configureRealm(); } - private void configureOIDCProvider() { + private void configureOIDCProvider() throws Exception { try (Keycloak keycloakAdmin = keycloak.getKeycloakAdminClient()) { + // Create Realm RealmRepresentation testRealm = new RealmRepresentation(); testRealm.setRealm(REALM); testRealm.setId(REALM); @@ -63,6 +68,12 @@ private void configureOIDCProvider() { 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(); @@ -83,6 +94,12 @@ private void configureOIDCProvider() { 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@acme.org"); @@ -156,6 +173,7 @@ private void configureRealm() { securityRealm.setAutomaticConfiguration(String.format("%s/realms/%s/.well-known/openid-configuration", keycloakUrl, REALM)); securityRealm.logoutFromOpenidProvider(true); securityRealm.setPostLogoutUrl(jenkins.url("OicLogout").toExternalForm()); + securityRealm.setUserFields(null, null, null, "groups"); sc.useAuthorizationStrategy(LoggedInAuthorizationStrategy.class); sc.save(); } @@ -168,7 +186,7 @@ public void fromJenkinsToKeycloak() { jenkins.clickLink("log in"); keycloakUtils.login(bob.getUserName()); - assertLoggedUser(bob); + assertLoggedUser(bob, "jenkinsAdmin"); jenkins.logout(); jenkins.open(); @@ -179,7 +197,7 @@ public void fromJenkinsToKeycloak() { clickLink("log in"); keycloakUtils.login(john.getUserName()); - assertLoggedUser(john); + assertLoggedUser(john, "jenkinsRead"); } @Test @@ -193,7 +211,7 @@ public void fromKeycloakToJenkins() throws Exception { jenkins.open(); jenkins.clickLink("log in"); // won't request a login, but log in directly with user from - assertLoggedUser(bob); + assertLoggedUser(bob, "jenkinsAdmin"); keycloakUtils.logout(bob); jenkins.open(); @@ -204,7 +222,7 @@ public void fromKeycloakToJenkins() throws Exception { jenkins.open(); jenkins.clickLink("log in"); keycloakUtils.login(john.getUserName()); - assertLoggedUser(john); + assertLoggedUser(john, "jenkinsRead"); } private void assertLoggedOut() { @@ -214,8 +232,17 @@ private void assertLoggedOut() { () -> keycloakUtils.getUser(keycloak.getAuthServerUrl(), REALM)); } - private void assertLoggedUser(KeycloakUtils.User expectedUser) { + private void assertLoggedUser(KeycloakUtils.User expectedUser, String roleToCheck) { assertThat("User has logged in Jenkins", jenkins.getCurrentUser().id(), is(expectedUser.getId())); + jenkins.visit("whoAmI"); + + // TODO if needed for more tests, consider to create a proper WhoAmI PageObject + // for this test we just need the authorities, which are displayed in UI as
  • "role_name"
  • , we can get + // all of "li" tags and check our roles are there + // Note the quotes surrounding the role name + Set allLiTagsInPage = this.driver.findElements(by.tagName("li")).stream().map(webElement -> StringUtils.defaultString(webElement.getText()) + .replace("\"", "")).collect(Collectors.toSet()); + assertThat("User has the expected roles inherited from keycloak", roleToCheck, is(in(allLiTagsInPage))); KeycloakUtils.User fromKeyCloak = keycloakUtils.getUser(keycloak.getAuthServerUrl(), REALM); assertThat("User has logged in keycloack", fromKeyCloak.getUserName(), is(expectedUser.getUserName())); From ac188a0b5877a091d296970169de532f45588ebd Mon Sep 17 00:00:00 2001 From: Francisco Javier Fernandez <31063239+fcojfernandez@users.noreply.github.com> Date: Mon, 2 Sep 2024 16:41:38 +0200 Subject: [PATCH 3/9] Update src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java Co-authored-by: James Nord --- .../org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java index 37cf588703..27d0653c0f 100644 --- a/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java +++ b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java @@ -3,7 +3,7 @@ /** * Security Realm provided by oic-auth plugin */ -@Describable({"Login with Openid Connect", "Login with Openid Connect"}) +@Describable("Login with Openid Connect") public class OicAuthSecurityRealm extends SecurityRealm { public OicAuthSecurityRealm(GlobalSecurityConfig context, String path) { From 7cfbcf90aeba34f0ccf5dcde38ae5f553dcd27c1 Mon Sep 17 00:00:00 2001 From: Francisco Javier Fernandez Gonzalez Date: Mon, 2 Sep 2024 17:24:56 +0200 Subject: [PATCH 4/9] Apply suggestions --- .../acceptance/po/OicAuthSecurityRealm.java | 2 +- .../utils/keycloack/KeycloakUtils.java | 3 +- src/test/java/plugins/OicAuthPluginTest.java | 38 +++++++++++++------ 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java index 27d0653c0f..fbee549863 100644 --- a/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java +++ b/src/main/java/org/jenkinsci/test/acceptance/po/OicAuthSecurityRealm.java @@ -20,7 +20,7 @@ public void setAutomaticConfiguration(String wellKnownEndpoint) { control("wellKnownOpenIDConfigurationUrl").set(wellKnownEndpoint); } - public void logoutFromOpenidProvider(boolean logout) { + public void setLogoutFromOpenidProvider(boolean logout) { Control check = control(by.checkbox("Logout from OpenID Provider")); if (logout) { check.check(); 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 index 1b4f9f6e0a..42fdc863cf 100644 --- a/src/main/java/org/jenkinsci/test/acceptance/utils/keycloack/KeycloakUtils.java +++ b/src/main/java/org/jenkinsci/test/acceptance/utils/keycloack/KeycloakUtils.java @@ -5,7 +5,6 @@ import org.jenkinsci.test.acceptance.po.CapybaraPortingLayerImpl; import org.jenkinsci.test.acceptance.utils.ElasticTime; import org.openqa.selenium.WebDriver; -import org.openqa.selenium.WebElement; import jakarta.inject.Inject; public class KeycloakUtils extends CapybaraPortingLayerImpl { @@ -35,7 +34,7 @@ public void login(String user, String passwd) { } - public User getUser(String keycloakUrl, String realm) { + public User getCurrentUser(String keycloakUrl, String realm) { driver.get(String.format("%s/realms/%s/account", keycloakUrl, realm)); waitFor(by.id("username"), 5); diff --git a/src/test/java/plugins/OicAuthPluginTest.java b/src/test/java/plugins/OicAuthPluginTest.java index 5612bb5a0c..c4dd80dda2 100644 --- a/src/test/java/plugins/OicAuthPluginTest.java +++ b/src/test/java/plugins/OicAuthPluginTest.java @@ -102,7 +102,7 @@ private void configureOIDCProvider() throws Exception { // Users UserRepresentation bob = new UserRepresentation(); - bob.setEmail("bob@acme.org"); + bob.setEmail("bob@jenkins-ath.test"); bob.setUsername("bob"); bob.setFirstName("Bob"); bob.setLastName("Smith"); @@ -117,7 +117,7 @@ private void configureOIDCProvider() throws Exception { theRealm.users().create(bob); UserRepresentation john = new UserRepresentation(); - john.setEmail("john@acme.org"); + john.setEmail("john@jenkins-ath.test"); john.setUsername("john"); john.setFirstName("John"); john.setLastName("Smith"); @@ -171,17 +171,24 @@ private void configureRealm() { OicAuthSecurityRealm securityRealm = sc.useRealm(OicAuthSecurityRealm.class); securityRealm.configureClient(CLIENT, CLIENT); securityRealm.setAutomaticConfiguration(String.format("%s/realms/%s/.well-known/openid-configuration", keycloakUrl, REALM)); - securityRealm.logoutFromOpenidProvider(true); + 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 fromJenkinsToKeycloak() { - final KeycloakUtils.User bob = new KeycloakUtils.User(userBobKeycloakId, "bob", "bob@acme.org", "Bob", "Smith"); - final KeycloakUtils.User john = new KeycloakUtils.User(userJohnKeycloakId, "john", "john@acme.org", "John", "Smith"); + 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"); @@ -192,7 +199,8 @@ public void fromJenkinsToKeycloak() { jenkins.open(); assertLoggedOut(); - // logout from Jenkins does mean logout from keycloak + // 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"); @@ -200,10 +208,16 @@ public void fromJenkinsToKeycloak() { 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 fromKeycloakToJenkins() throws Exception { - final KeycloakUtils.User bob = new KeycloakUtils.User(userBobKeycloakId, "bob", "bob@acme.org", "Bob", "Smith"); - final KeycloakUtils.User john = new KeycloakUtils.User(userJohnKeycloakId, "john", "john@acme.org", "John", "Smith"); + 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)); @@ -229,7 +243,7 @@ private void assertLoggedOut() { assertNull("User has logged out from Jenkins", jenkins.getCurrentUser().id()); assertThrows("User has logged out from keycloak", NoSuchElementException.class, - () -> keycloakUtils.getUser(keycloak.getAuthServerUrl(), REALM)); + () -> keycloakUtils.getCurrentUser(keycloak.getAuthServerUrl(), REALM)); } private void assertLoggedUser(KeycloakUtils.User expectedUser, String roleToCheck) { @@ -244,7 +258,7 @@ private void assertLoggedUser(KeycloakUtils.User expectedUser, String roleToChec .replace("\"", "")).collect(Collectors.toSet()); assertThat("User has the expected roles inherited from keycloak", roleToCheck, is(in(allLiTagsInPage))); - KeycloakUtils.User fromKeyCloak = keycloakUtils.getUser(keycloak.getAuthServerUrl(), REALM); + 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())); From 95e5e3172065f542151b0364b243d1c6f3d818d7 Mon Sep 17 00:00:00 2001 From: Francisco Javier Fernandez Gonzalez Date: Mon, 2 Sep 2024 17:47:21 +0200 Subject: [PATCH 5/9] Add the WhoAmI page --- .../jenkinsci/test/acceptance/po/WhoAmI.java | 11 +++++++++++ src/test/java/plugins/OicAuthPluginTest.java | 19 ++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/jenkinsci/test/acceptance/po/WhoAmI.java 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/test/java/plugins/OicAuthPluginTest.java b/src/test/java/plugins/OicAuthPluginTest.java index c4dd80dda2..a08f4849a2 100644 --- a/src/test/java/plugins/OicAuthPluginTest.java +++ b/src/test/java/plugins/OicAuthPluginTest.java @@ -4,16 +4,16 @@ import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; 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; @@ -248,15 +248,12 @@ private void assertLoggedOut() { private void assertLoggedUser(KeycloakUtils.User expectedUser, String roleToCheck) { assertThat("User has logged in Jenkins", jenkins.getCurrentUser().id(), is(expectedUser.getId())); - jenkins.visit("whoAmI"); - - // TODO if needed for more tests, consider to create a proper WhoAmI PageObject - // for this test we just need the authorities, which are displayed in UI as
  • "role_name"
  • , we can get - // all of "li" tags and check our roles are there - // Note the quotes surrounding the role name - Set allLiTagsInPage = this.driver.findElements(by.tagName("li")).stream().map(webElement -> StringUtils.defaultString(webElement.getText()) - .replace("\"", "")).collect(Collectors.toSet()); - assertThat("User has the expected roles inherited from keycloak", roleToCheck, is(in(allLiTagsInPage))); + + 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())); From 067ae95da4dd857dfc0a2f1d70285145eece032f Mon Sep 17 00:00:00 2001 From: James Nord Date: Wed, 4 Sep 2024 18:13:13 +0100 Subject: [PATCH 6/9] Fix permissions on the docker socket TestContainers does not use docker, but talks directly to the docker socket. The permissions on this socket come from the host where it is mapped and the docker groupid may not match what we have in the container. So allow th arg to be passed through at build time and add the ath-user to the docker group so it has the permissions. We retain the legacy suid on the docker binary as we publish the container and there is only a single test so far using this test-containers. (this can be revistied if required). --- Jenkinsfile | 2 +- src/main/resources/ath-container/Dockerfile | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 709730c771..5e637637a7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -122,7 +122,7 @@ for (int i = 0; i < splits.size(); i++) { retryCounts = retryCounts + 1 // increment the retry count before allocating a node in case it fails node(nodeLabel) { checkout scm - def image = skipImageBuild ? docker.image('jenkins/ath') : docker.build('jenkins/ath', '--build-arg uid="$(id -u)" --build-arg gid="$(id -g)" ./src/main/resources/ath-container/') + def image = skipImageBuild ? docker.image('jenkins/ath') : docker.build('jenkins/ath', '--build-arg uid="$(id -u)" --build-arg gid="$(id -g)" --build-arg dockergid="$(getent group docker | cut -d: -f3)" ./src/main/resources/ath-container/') sh 'mkdir -p target/ath-reports && chmod a+rwx target/ath-reports' def cwd = pwd() image.inside("-v /var/run/docker.sock:/var/run/docker.sock -v '${cwd}/target/ath-reports:/reports:rw' --shm-size 2g") { diff --git a/src/main/resources/ath-container/Dockerfile b/src/main/resources/ath-container/Dockerfile index 5720ead197..6f19931f5b 100644 --- a/src/main/resources/ath-container/Dockerfile +++ b/src/main/resources/ath-container/Dockerfile @@ -37,6 +37,13 @@ RUN install -m 0755 -d /etc/apt/keyrings \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# Despite the docker SUID hack below, test-containers accesses /var/run/docker.sock directly and so we can not rely on the SUID hack. +# Rather take the docker user group as an arg and make the ath-user a member of that group +# we retain the suid workaround as this method requires a local build of the container +# we need to do this before we install docker so that any files have the correct permission +ARG dockergid=1002 +RUN groupadd docker -g $dockergid + # Docker installation according to https://docs.docker.com/engine/install/ubuntu/ ARG DOCKER_BUILDX_VERSION=0.16.2 ARG DOCKER_VERSION=27.1.2 @@ -84,10 +91,11 @@ EXPOSE 5942 RUN deluser --remove-home ubuntu \ && groupadd ath-user -g $gid \ - && useradd ath-user -l -c 'ATH User' -u $uid -g $gid -m -d /home/ath-user -s /bin/bash + && useradd ath-user -l -c 'ATH User' -u $uid -g $gid -G docker -m -d /home/ath-user -s /bin/bash -# Set SUID and SGID for docker binary so it can communicate with mapped socket its uid:gid we can not control. Alternative -# approach used for this is adding ath-user to the group of /var/run/docker.sock but that require root permission we do not +# Set SUID and SGID for docker binary so it can communicate with mapped socket its uid:gid we can not control. This alternative +# approach is used as adding ath-user to the group of /var/run/docker.sock is a build time option and any published container may +# not match what is needed, and changing this at runtime would require root permission we do not # have in ENTRYPOINT as the container is started as ath-user. RUN chmod ug+s /usr/bin/docker* From f336732785122935d85a2b5f847c6cf699138c0a Mon Sep 17 00:00:00 2001 From: James Nord Date: Thu, 5 Sep 2024 12:20:37 +0100 Subject: [PATCH 7/9] Create the docker group before we install any packages Installing packages can create groups, and the group id used can be dynamic. As such the group we want to use may already be in use. Attempt to workaround this issue by creating the group before installaing any packages so that the group is more likely to be available. --- src/main/resources/ath-container/Dockerfile | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/resources/ath-container/Dockerfile b/src/main/resources/ath-container/Dockerfile index 6f19931f5b..3d3fb90e37 100644 --- a/src/main/resources/ath-container/Dockerfile +++ b/src/main/resources/ath-container/Dockerfile @@ -7,6 +7,15 @@ FROM ubuntu:noble ENV LANG C.UTF-8 SHELL ["/bin/bash", "-o", "pipefail", "-c"] +# Despite the docker SUID hack below, test-containers accesses /var/run/docker.sock directly and so we can not rely on the SUID hack. +# Rather take the docker user group as an arg and make the ath-user a member of that group +# we retain the suid workaround as this method requires a local build of the container +# we need to do this before we install docker so that any files have the correct permission +# additionally we do this before installing any other packages so that the required groupid will not already be in use + +ARG dockergid=1002 +RUN groupadd docker -g $dockergid + # hadolint ignore=DL3008 RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install --no-install-recommends -y \ @@ -37,13 +46,6 @@ RUN install -m 0755 -d /etc/apt/keyrings \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Despite the docker SUID hack below, test-containers accesses /var/run/docker.sock directly and so we can not rely on the SUID hack. -# Rather take the docker user group as an arg and make the ath-user a member of that group -# we retain the suid workaround as this method requires a local build of the container -# we need to do this before we install docker so that any files have the correct permission -ARG dockergid=1002 -RUN groupadd docker -g $dockergid - # Docker installation according to https://docs.docker.com/engine/install/ubuntu/ ARG DOCKER_BUILDX_VERSION=0.16.2 ARG DOCKER_VERSION=27.1.2 From 3cc8be352c38f50f9282033a52919d3c9cc8b085 Mon Sep 17 00:00:00 2001 From: James Nord Date: Thu, 5 Sep 2024 14:07:53 +0100 Subject: [PATCH 8/9] Add some debugging --- Jenkinsfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Jenkinsfile b/Jenkinsfile index 5e637637a7..c2f97c9ab3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -140,6 +140,8 @@ for (int i = 0; i < splits.size(); i++) { allowEmptyResults: true ) { sh """ + id + ls -lan /var/run/docker.sock set-java.sh ${jdk} eval \$(vnc.sh) java -version From 72443fb90bcc602cd34b3ebe9f05bfc62d0d727f Mon Sep 17 00:00:00 2001 From: James Nord Date: Thu, 5 Sep 2024 14:45:05 +0100 Subject: [PATCH 9/9] attempt to fix and simplify docker setup remove the seuid hacks and attempt to add the docker group and replace with a call to add the user to the docker group from the system. --- Jenkinsfile | 5 +++-- src/main/resources/ath-container/Dockerfile | 17 +---------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index c2f97c9ab3..9ffb4cc728 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -122,10 +122,11 @@ for (int i = 0; i < splits.size(); i++) { retryCounts = retryCounts + 1 // increment the retry count before allocating a node in case it fails node(nodeLabel) { checkout scm - def image = skipImageBuild ? docker.image('jenkins/ath') : docker.build('jenkins/ath', '--build-arg uid="$(id -u)" --build-arg gid="$(id -g)" --build-arg dockergid="$(getent group docker | cut -d: -f3)" ./src/main/resources/ath-container/') + def image = skipImageBuild ? docker.image('jenkins/ath') : docker.build('jenkins/ath', '--build-arg uid="$(id -u)" --build-arg gid="$(id -g)" ./src/main/resources/ath-container/') sh 'mkdir -p target/ath-reports && chmod a+rwx target/ath-reports' def cwd = pwd() - image.inside("-v /var/run/docker.sock:/var/run/docker.sock -v '${cwd}/target/ath-reports:/reports:rw' --shm-size 2g") { + def dockergid = sh label: 'get docker group', returnStdout: true, script: 'getent group docker | cut -d: -f3' + image.inside("--group-add ${dockergid} -v /var/run/docker.sock:/var/run/docker.sock -v '${cwd}/target/ath-reports:/reports:rw' --shm-size 2g") { def exclusions = splits.get(index).join('\n') writeFile file: 'excludes.txt', text: exclusions infra.withArtifactCachingProxy { diff --git a/src/main/resources/ath-container/Dockerfile b/src/main/resources/ath-container/Dockerfile index ac847e93f5..ab34cf3c24 100644 --- a/src/main/resources/ath-container/Dockerfile +++ b/src/main/resources/ath-container/Dockerfile @@ -7,15 +7,6 @@ FROM ubuntu:noble ENV LANG C.UTF-8 SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# Despite the docker SUID hack below, test-containers accesses /var/run/docker.sock directly and so we can not rely on the SUID hack. -# Rather take the docker user group as an arg and make the ath-user a member of that group -# we retain the suid workaround as this method requires a local build of the container -# we need to do this before we install docker so that any files have the correct permission -# additionally we do this before installing any other packages so that the required groupid will not already be in use - -ARG dockergid=1002 -RUN groupadd docker -g $dockergid - # hadolint ignore=DL3008 RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install --no-install-recommends -y \ @@ -93,13 +84,7 @@ EXPOSE 5942 RUN deluser --remove-home ubuntu \ && groupadd ath-user -g $gid \ - && useradd ath-user -l -c 'ATH User' -u $uid -g $gid -G docker -m -d /home/ath-user -s /bin/bash - -# Set SUID and SGID for docker binary so it can communicate with mapped socket its uid:gid we can not control. This alternative -# approach is used as adding ath-user to the group of /var/run/docker.sock is a build time option and any published container may -# not match what is needed, and changing this at runtime would require root permission we do not -# have in ENTRYPOINT as the container is started as ath-user. -RUN chmod ug+s /usr/bin/docker* + && useradd ath-user -l -c 'ATH User' -u $uid -g $gid -m -d /home/ath-user -s /bin/bash # Give permission to modify the alternatives links to change the java version in use RUN chmod u+s "$(which update-alternatives)"