From 81e946fae9d5d527e20d973b17642dd781416ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eva=20M=C3=BCller?= Date: Wed, 22 Jan 2025 12:00:38 +0100 Subject: [PATCH] Restructure and split tests --- .../plugins/oic/OicSecurityRealm.java | 65 +- .../plugins/oic/MockHttpServletResponse.java | 199 +++ .../plugins/oic/OicSecurityRealmTest.java | 94 ++ .../plugins/oic/PluginApiTokenTest.java | 113 ++ .../plugins/oic/PluginRefreshTokenTest.java | 194 +++ .../org/jenkinsci/plugins/oic/PluginTest.java | 1067 ++++------------- .../org/jenkinsci/plugins/oic/TestRealm.java | 7 + .../plugins/oic/plugintest/Mocks.java | 115 ++ .../plugins/oic/plugintest/TestHelper.java | 369 ++++++ 9 files changed, 1386 insertions(+), 837 deletions(-) create mode 100644 src/test/java/org/jenkinsci/plugins/oic/MockHttpServletResponse.java create mode 100644 src/test/java/org/jenkinsci/plugins/oic/PluginApiTokenTest.java create mode 100644 src/test/java/org/jenkinsci/plugins/oic/PluginRefreshTokenTest.java create mode 100644 src/test/java/org/jenkinsci/plugins/oic/plugintest/Mocks.java create mode 100644 src/test/java/org/jenkinsci/plugins/oic/plugintest/TestHelper.java diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java b/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java index e99d91eb..9f98ebb8 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java @@ -1344,14 +1344,12 @@ public void doFinishLogin(StaplerRequest2 request, StaplerResponse2 response) th */ public boolean handleTokenExpiration(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException, ServletException { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (httpRequest.getRequestURI().endsWith("/logout")) { - // No need to refresh token when logging out + if (isLogoutRequest(httpRequest)) { return true; } - if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (isAnonymousOrNoAuthentication(authentication)) { return true; } @@ -1360,32 +1358,17 @@ public boolean handleTokenExpiration(HttpServletRequest httpRequest, HttpServlet return true; } - if (isAllowTokenAccessWithoutOicSession()) { - // check if this is a valid api token based request - String authHeader = httpRequest.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Basic ")) { - String token = new String(Base64.getDecoder().decode(authHeader.substring(6)), StandardCharsets.UTF_8) - .split(":")[1]; - - ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class); - if (apiTokenProperty != null && apiTokenProperty.matchesPassword(token)) { - // this was a valid jenkins token being used, exit this filter and let - // the rest of chain be processed - return true; - } // else do nothing and continue evaluating this request - } - } - OicCredentials credentials = user.getProperty(OicCredentials.class); - if (credentials == null) { return true; } + if (isValidApiTokenRequest(httpRequest, user)) { + return true; + } + if (isExpired(credentials)) { - if (serverConfiguration.toProviderMetadata().getGrantTypes() != null - && serverConfiguration.toProviderMetadata().getGrantTypes().contains(GrantType.REFRESH_TOKEN) - && !Strings.isNullOrEmpty(credentials.getRefreshToken())) { + if (canRefreshToken(credentials)) { LOGGER.log(Level.FINEST, "Attempting to refresh credential for user: {0}", user.getId()); boolean retVal = refreshExpiredToken(user.getId(), credentials, httpRequest, httpResponse); LOGGER.log(Level.FINEST, "Refresh credential for user returned {0}", retVal); @@ -1399,6 +1382,38 @@ public boolean handleTokenExpiration(HttpServletRequest httpRequest, HttpServlet return true; } + boolean isLogoutRequest(HttpServletRequest request) { + return request.getRequestURI().endsWith("/logout"); + } + + boolean isAnonymousOrNoAuthentication(Authentication authentication) { + return authentication == null || authentication instanceof AnonymousAuthenticationToken; + } + + boolean isValidApiTokenRequest(HttpServletRequest httpRequest, User user) { + if (isAllowTokenAccessWithoutOicSession()) { + // check if this is a valid api token based request + String authHeader = httpRequest.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Basic ")) { + String token = new String(Base64.getDecoder().decode(authHeader.substring(6)), StandardCharsets.UTF_8) + .split(":")[1]; + + // this was a valid jenkins token being used, exit this filter and let + // the rest of chain be processed + // else do nothing and continue evaluating this request + ApiTokenProperty apiTokenProperty = user.getProperty(ApiTokenProperty.class); + return apiTokenProperty != null && apiTokenProperty.matchesPassword(token); + } + } + return false; + } + + boolean canRefreshToken(OicCredentials credentials) { + return serverConfiguration.toProviderMetadata().getGrantTypes() != null + && serverConfiguration.toProviderMetadata().getGrantTypes().contains(GrantType.REFRESH_TOKEN) + && !Strings.isNullOrEmpty(credentials.getRefreshToken()); + } + private void redirectToLoginUrl(HttpServletRequest req, HttpServletResponse res) throws IOException { if (req != null && (req.getSession(false) != null || Strings.isNullOrEmpty(req.getHeader("Authorization")))) { req.getSession().invalidate(); diff --git a/src/test/java/org/jenkinsci/plugins/oic/MockHttpServletResponse.java b/src/test/java/org/jenkinsci/plugins/oic/MockHttpServletResponse.java new file mode 100644 index 00000000..e47e982a --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/oic/MockHttpServletResponse.java @@ -0,0 +1,199 @@ +package org.jenkinsci.plugins.oic; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Collection; +import java.util.Locale; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; + +public class MockHttpServletResponse implements HttpServletResponse { + + public MockHttpServletResponse() {} + + @Override + public void addCookie(Cookie cookie) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsHeader(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeURL(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeRedirectURL(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeUrl(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public String encodeRedirectUrl(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public void sendError(int i, String s) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void sendError(int i) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void sendRedirect(String s) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void setDateHeader(String s, long l) { + throw new UnsupportedOperationException(); + } + + @Override + public void addDateHeader(String s, long l) { + throw new UnsupportedOperationException(); + } + + @Override + public void setHeader(String s, String s1) { + throw new UnsupportedOperationException(); + } + + @Override + public void addHeader(String s, String s1) { + throw new UnsupportedOperationException(); + } + + @Override + public void setIntHeader(String s, int i) { + throw new UnsupportedOperationException(); + } + + @Override + public void addIntHeader(String s, int i) { + throw new UnsupportedOperationException(); + } + + @Override + public void setStatus(int i) { + throw new UnsupportedOperationException(); + } + + @Override + public void setStatus(int i, String s) { + throw new UnsupportedOperationException(); + } + + @Override + public int getStatus() { + throw new UnsupportedOperationException(); + } + + @Override + public String getHeader(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public Collection getHeaders(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public Collection getHeaderNames() { + throw new UnsupportedOperationException(); + } + + @Override + public String getCharacterEncoding() { + throw new UnsupportedOperationException(); + } + + @Override + public String getContentType() { + throw new UnsupportedOperationException(); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public PrintWriter getWriter() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void setCharacterEncoding(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentLength(int i) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentLengthLong(long l) { + throw new UnsupportedOperationException(); + } + + @Override + public void setContentType(String s) { + throw new UnsupportedOperationException(); + } + + @Override + public void setBufferSize(int i) { + throw new UnsupportedOperationException(); + } + + @Override + public int getBufferSize() { + throw new UnsupportedOperationException(); + } + + @Override + public void flushBuffer() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void resetBuffer() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isCommitted() { + throw new UnsupportedOperationException(); + } + + @Override + public void reset() { + throw new UnsupportedOperationException(); + } + + @Override + public void setLocale(Locale locale) { + throw new UnsupportedOperationException(); + } + + @Override + public Locale getLocale() { + throw new UnsupportedOperationException(); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/oic/OicSecurityRealmTest.java b/src/test/java/org/jenkinsci/plugins/oic/OicSecurityRealmTest.java index 32d859cc..86ab2f31 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/OicSecurityRealmTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/OicSecurityRealmTest.java @@ -2,7 +2,11 @@ import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import com.github.tomakehurst.wiremock.junit.WireMockRule; +import hudson.model.User; +import hudson.security.SecurityRealm; import hudson.util.Secret; +import java.util.ArrayList; +import java.util.List; import org.acegisecurity.AuthenticationManager; import org.acegisecurity.BadCredentialsException; import org.acegisecurity.GrantedAuthority; @@ -12,12 +16,15 @@ import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.bcrypt.BCrypt; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class OicSecurityRealmTest { @@ -142,4 +149,91 @@ public void testShouldCheckEscapeHatchWithHashedPassword() throws Exception { assertFalse(realm.doCheckEscapeHatch("otherUsername", escapeHatchPassword)); assertFalse(realm.doCheckEscapeHatch(escapeHatchUsername, "wrongPassword")); } + + @Test + public void testHandleTokenExpiration_logoutRequestUri() throws Exception { + TestRealm realm = + new TestRealm.Builder(wireMockRule).WithMinimalDefaults().build(); + + MockHttpServletRequest request = new MockHttpServletRequest() { + @Override + public String getRequestURI() { + return "/logout"; + } + }; + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertTrue(realm.isLogoutRequest(request)); + assertTrue(realm.handleTokenExpiration(request, response)); + } + + @Test + public void testHandleTokenExpiration_noAuthenticationOrAnonymous() throws Exception { + TestRealm realm = + new TestRealm.Builder(wireMockRule).WithMinimalDefaults().build(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + SecurityContextHolder.getContext().setAuthentication(null); + assertFalse(realm.isLogoutRequest(request)); + assertTrue(realm.isAnonymousOrNoAuthentication(null)); + assertTrue(realm.handleTokenExpiration(request, response)); + + String key = "testKey"; + Object principal = "testUser"; + + List grantedAuthorities = new ArrayList<>(); + grantedAuthorities.add(SecurityRealm.AUTHENTICATED_AUTHORITY2); + org.springframework.security.authentication.AnonymousAuthenticationToken token = + new org.springframework.security.authentication.AnonymousAuthenticationToken( + key, principal, grantedAuthorities); + SecurityContextHolder.getContext().setAuthentication(token); + + assertFalse(realm.isLogoutRequest(request)); + assertTrue(realm.isAnonymousOrNoAuthentication(token)); + assertTrue(realm.handleTokenExpiration(request, response)); + } + + @Test + public void testHandleTokenExpiration_noOicCredentials() throws Exception { + TestRealm realm = + new TestRealm.Builder(wireMockRule).WithMinimalDefaults().build(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertFalse(realm.isLogoutRequest(request)); + Authentication a = SecurityContextHolder.getContext().getAuthentication(); + assertFalse(realm.isAnonymousOrNoAuthentication(a)); + + User user = User.get2(a); + assertNotNull(user); + assertNull(user.getProperty(OicCredentials.class)); + assertTrue(realm.handleTokenExpiration(request, response)); + } + + @Test + public void testIsValidApiTokenRequest_NoTokenAccessWithoutOicSession() throws Exception { + TestRealm realm = + new TestRealm.Builder(wireMockRule).WithMinimalDefaults().build(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + assertFalse(realm.isLogoutRequest(request)); + + List grantedAuthorities = new ArrayList<>(); + grantedAuthorities.add(SecurityRealm.AUTHENTICATED_AUTHORITY2); + org.springframework.security.authentication.UsernamePasswordAuthenticationToken token = + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + "test-user", "", grantedAuthorities); + SecurityContextHolder.getContext().setAuthentication(token); + + assertFalse(realm.isAnonymousOrNoAuthentication(token)); + + User user = User.get2(token); + assertNotNull(user); + user.addProperty(new OicCredentials("test", "test", "test", 1L, 1L, 1L)); + assertNotNull(user.getProperty(OicCredentials.class)); + assertFalse(realm.isValidApiTokenRequest(request, user)); + } } diff --git a/src/test/java/org/jenkinsci/plugins/oic/PluginApiTokenTest.java b/src/test/java/org/jenkinsci/plugins/oic/PluginApiTokenTest.java new file mode 100644 index 00000000..981ff605 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/oic/PluginApiTokenTest.java @@ -0,0 +1,113 @@ +package org.jenkinsci.plugins.oic; + +import com.github.tomakehurst.wiremock.common.ConsoleNotifier; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import hudson.model.User; +import java.net.http.HttpResponse; +import jenkins.model.Jenkins; +import jenkins.security.ApiTokenProperty; +import org.hamcrest.MatcherAssert; +import org.jenkinsci.plugins.oic.plugintest.Mocks; +import org.jenkinsci.plugins.oic.plugintest.TestHelper; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.DisableOnDebug; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNotNull; + +/** + * goes through a login scenario, the openid provider is mocked and always + * returns state. We aren't checking if openid connect or if the openid + * connect implementation works. Rather we are only checking if the jenkins + * interaction works and if the plugin code works. + */ +public class PluginApiTokenTest { + + @Rule + public WireMockRule wireMockRule = new WireMockRule( + new WireMockConfiguration() + .dynamicPort() + .dynamicHttpsPort() + .notifier(new ConsoleNotifier(new DisableOnDebug(null).isDebugging())), + true); + + @Rule + public JenkinsRule jenkinsRule = new JenkinsRule(); + + private JenkinsRule.WebClient webClient; + private Jenkins jenkins; + + @Before + public void setUp() { + jenkins = jenkinsRule.getInstance(); + webClient = jenkinsRule.createWebClient(); + if (new DisableOnDebug(null).isDebugging()) { + webClient.getOptions().setTimeout(0); + } + } + + @Test + public void testAccessJenkinsUsingApiTokens() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code"); + + TestRealm testRealm = new TestRealm.Builder(wireMockRule) + .WithMinimalDefaults() + .WithAutomanualconfigure(true) + // explicitly ensure allowTokenAccessWithoutOicSession is disabled + .WithAllowTokenAccessWithoutOicSession(false) + .build(); + + jenkins.setSecurityRealm(testRealm); + + // login and assert normal auth is working + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule, TestHelper::withoutRefreshToken); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertTestUser(webClient); + + User user = User.getById(TestHelper.TEST_USER_USERNAME, false); + assertNotNull("User must not be null", user); + + // create a jenkins api token for the test user + String token = user.getProperty(ApiTokenProperty.class).generateNewToken("foo").plainValue; + + // validate that the token can be used + HttpResponse rsp = + TestHelper.getPageWithGet(jenkinsRule, TestHelper.TEST_USER_USERNAME, token, "/whoAmI/api/xml"); + MatcherAssert.assertThat("response should have been 200\n" + rsp.body(), rsp.statusCode(), is(200)); + + MatcherAssert.assertThat( + "response should have been 200\n" + rsp.body(), + rsp.body(), + containsString("true")); + + // expired oic session tokens, do not refreshed + TestHelper.expire(webClient); + + // the default behavior expects there to be a valid oic session, so token based + // access should now fail (unauthorized) + rsp = TestHelper.getPageWithGet(jenkinsRule, TestHelper.TEST_USER_USERNAME, token, "/whoAmI/api/xml"); + MatcherAssert.assertThat("response should have been 302\n" + rsp.body(), rsp.statusCode(), is(302)); + + // enable "traditional api token access" + testRealm.setAllowTokenAccessWithoutOicSession(true); + + // verify that jenkins api token is now working again + rsp = TestHelper.getPageWithGet(jenkinsRule, TestHelper.TEST_USER_USERNAME, token, "/whoAmI/api/xml"); + MatcherAssert.assertThat("response should have been 200\n" + rsp.body(), rsp.statusCode(), is(200)); + MatcherAssert.assertThat( + "response should have been 200\n" + rsp.body(), + rsp.body(), + containsString("true")); + + // logout + rsp = TestHelper.getPageWithGet(jenkinsRule, TestHelper.TEST_USER_USERNAME, token, "/logout"); + MatcherAssert.assertThat("response should have been 200\n" + rsp.body(), rsp.statusCode(), is(200)); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/oic/PluginRefreshTokenTest.java b/src/test/java/org/jenkinsci/plugins/oic/PluginRefreshTokenTest.java new file mode 100644 index 00000000..1759dd56 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/oic/PluginRefreshTokenTest.java @@ -0,0 +1,194 @@ +package org.jenkinsci.plugins.oic; + +import com.github.tomakehurst.wiremock.common.ConsoleNotifier; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.nimbusds.oauth2.sdk.GrantType; +import java.net.http.HttpResponse; +import jenkins.model.Jenkins; +import org.hamcrest.MatcherAssert; +import org.jenkinsci.plugins.oic.plugintest.Mocks; +import org.jenkinsci.plugins.oic.plugintest.TestHelper; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.DisableOnDebug; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.Url; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.notMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.hamcrest.Matchers.is; +import static org.jenkinsci.plugins.oic.TestRealm.EMAIL_FIELD; +import static org.jenkinsci.plugins.oic.TestRealm.GROUPS_FIELD; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * goes through a login scenario, the openid provider is mocked and always + * returns state. We aren't checking if openid connect or if the openid + * connect implementation works. Rather we are only checking if the jenkins + * interaction works and if the plugin code works. + */ +@Url("https://jenkins.io/blog/2018/01/13/jep-200/") +public class PluginRefreshTokenTest { + + private static final String[] TEST_USER_GROUPS_REFRESHED = new String[] {"group1", "group2", "group3"}; + + @Rule + public WireMockRule wireMockRule = new WireMockRule( + new WireMockConfiguration() + .dynamicPort() + .dynamicHttpsPort() + .notifier(new ConsoleNotifier(new DisableOnDebug(null).isDebugging())), + true); + + @Rule + public JenkinsRule jenkinsRule = new JenkinsRule(); + + private JenkinsRule.WebClient webClient; + private Jenkins jenkins; + + @Before + public void setUp() { + jenkins = jenkinsRule.getInstance(); + webClient = jenkinsRule.createWebClient(); + if (new DisableOnDebug(null).isDebugging()) { + webClient.getOptions().setTimeout(0); + } + } + + @Test + public void testConfigurationWithAutoConfiguration_withRefreshToken() throws Exception { + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code", "refresh_token"); + TestRealm testRealm = new TestRealm.Builder(wireMockRule) + .WithMinimalDefaults().WithAutomanualconfigure(true).build(); + jenkins.setSecurityRealm(testRealm); + assertTrue( + "Refresh token should be enabled", + testRealm + .getServerConfiguration() + .toProviderMetadata() + .getGrantTypes() + .contains(GrantType.REFRESH_TOKEN)); + } + + @Test + public void testRefreshToken_validAndExtendedToken() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code", "refresh_token"); + jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); + // user groups on first login + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); + assertFalse( + "User should not be part of group " + TEST_USER_GROUPS_REFRESHED[2], + user.getAuthorities().contains(TEST_USER_GROUPS_REFRESHED[2])); + + // refresh user with different groups + Mocks.mockTokenReturnsIdTokenWithValues( + wireMockRule, TestHelper.setUpKeyValuesWithGroup(TEST_USER_GROUPS_REFRESHED)); + Mocks.mockUserInfoWithGroups(wireMockRule, TEST_USER_GROUPS_REFRESHED); + TestHelper.expire(webClient); + webClient.goTo(jenkins.getSearchUrl()); + + user = TestHelper.assertTestUser(webClient); + assertTrue( + "User should be part of group " + TEST_USER_GROUPS_REFRESHED[2], + user.getAuthorities().contains(TEST_USER_GROUPS_REFRESHED[2])); + + verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(containing("grant_type=refresh_token"))); + } + + @Test + public void testRefreshTokenAndTokenExpiration_withoutRefreshToken() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code"); + jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); + // login + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule, TestHelper::withoutRefreshToken); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertTestUser(webClient); + // expired token not refreshed + TestHelper.expire(webClient); + // use an actual HttpClient to make checking redirects easier + HttpResponse rsp = TestHelper.getPageWithGet(jenkinsRule, "/manage"); + MatcherAssert.assertThat("response should have been 302\n" + rsp.body(), rsp.statusCode(), is(302)); + verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(notMatching(".*grant_type=refresh_token.*"))); + } + + @Test + public void testRefreshTokenWithTokenExpirationCheckDisabled_withoutRefreshToken() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code"); + var realm = new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true); + realm.setTokenExpirationCheckDisabled(true); + jenkins.setSecurityRealm(realm); + // login + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertTestUser(webClient); + + TestHelper.expire(webClient); + webClient.goTo(jenkins.getSearchUrl()); + + verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(notMatching(".*grant_type=refresh_token.*"))); + } + + @Test + public void testRefreshTokenWithTokenExpirationCheckDisabled_expiredRefreshToken() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code", "refresh_token"); + TestRealm testRealm = new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true); + testRealm.setTokenExpirationCheckDisabled(true); + jenkins.setSecurityRealm(testRealm); + // login + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertTestUser(webClient); + + wireMockRule.stubFor(post(urlPathEqualTo("/token")) + .willReturn(aResponse() + .withStatus(400) + .withHeader("Content-Type", "application/json") + .withBody("{ \"error\": \"invalid_grant\" }"))); + TestHelper.expire(webClient); + webClient.goTo(jenkins.getSearchUrl(), ""); + + verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(containing("grant_type=refresh_token"))); + } + + @Test + public void testRefreshTokenAndTokenExpiration_expiredRefreshToken() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code", "refresh_token"); + TestRealm testRealm = new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true); + jenkins.setSecurityRealm(testRealm); + // login + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertTestUser(webClient); + + wireMockRule.stubFor(post(urlPathEqualTo("/token")) + .willReturn(aResponse() + .withStatus(400) + .withHeader("Content-Type", "application/json") + .withBody("{ \"error\": \"invalid_grant\" }"))); + TestHelper.expire(webClient); + webClient.assertFails(jenkins.getSearchUrl(), 500); + + verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(containing("grant_type=refresh_token"))); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java index 2b502218..70d70525 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java @@ -3,53 +3,29 @@ import com.github.tomakehurst.wiremock.common.ConsoleNotifier; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import com.github.tomakehurst.wiremock.junit.WireMockRule; -import com.google.api.client.auth.openidconnect.IdToken; -import com.google.api.client.json.gson.GsonFactory; -import com.google.api.client.json.webtoken.JsonWebSignature; -import com.google.api.client.json.webtoken.JsonWebToken; -import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.google.gson.JsonNull; -import com.nimbusds.oauth2.sdk.GrantType; import com.nimbusds.oauth2.sdk.Scope; -import edu.umd.cs.findbugs.annotations.CheckForNull; -import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; import hudson.model.User; import hudson.tasks.Mailer; import hudson.util.VersionNumber; -import jakarta.servlet.http.HttpSession; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.security.KeyPair; -import java.security.KeyPairGenerator; import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.time.Clock; -import java.util.Arrays; import java.util.Base64; -import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.net.ssl.SSLException; + +import jakarta.servlet.http.HttpSession; import jenkins.model.Jenkins; -import jenkins.security.ApiTokenProperty; import jenkins.security.LastGrantedAuthoritiesProperty; -import org.hamcrest.MatcherAssert; import org.htmlunit.CookieManager; import org.htmlunit.html.HtmlPage; import org.htmlunit.util.Cookie; +import org.jenkinsci.plugins.oic.plugintest.Mocks; +import org.jenkinsci.plugins.oic.plugintest.TestHelper; import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -60,8 +36,6 @@ import org.jvnet.hudson.test.Url; import org.kohsuke.stapler.Stapler; import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.xml.sax.SAXException; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.absent; @@ -72,20 +46,15 @@ import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.matching; import static com.github.tomakehurst.wiremock.client.WireMock.notMatching; -import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.verify; -import static com.google.gson.JsonParser.parseString; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; -import static org.hamcrest.Matchers.is; import static org.jenkinsci.plugins.oic.TestRealm.EMAIL_FIELD; import static org.jenkinsci.plugins.oic.TestRealm.FULL_NAME_FIELD; import static org.jenkinsci.plugins.oic.TestRealm.GROUPS_FIELD; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -100,11 +69,7 @@ */ @Url("https://jenkins.io/blog/2018/01/13/jep-200/") public class PluginTest { - private static final String TEST_USER_USERNAME = "testUser"; - private static final String TEST_USER_EMAIL_ADDRESS = "test@jenkins.oic"; - private static final String TEST_USER_FULL_NAME = "Oic Test User"; - private static final String[] TEST_USER_GROUPS = new String[] {"group1", "group2"}; - private static final String[] TEST_USER_GROUPS_REFRESHED = new String[] {"group1", "group2", "group3"}; + private static final List> TEST_USER_GROUPS_MAP = List.of(Map.of("id", "id1", "name", "group1"), Map.of("id", "id2", "name", "group2")); @@ -133,14 +98,14 @@ public void setUp() { @Test public void testLoginWithDefaults() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - configureTestRealm(sc -> {}); - assertAnonymous(); - browseLoginPage(); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfTestGroups(user); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + TestHelper.configureTestRealm(wireMockRule, jenkins, sc -> {}); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfTestGroups(user); verify(getRequestedFor(urlPathEqualTo("/authorization")) .withQueryParam("scope", equalTo("openid email")) @@ -155,26 +120,26 @@ public void testLoginWithDefaults() throws Exception { @Test public void testLoginWithDefaultsUntrustedTLSFails() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); TestRealm.Builder builder = new TestRealm.Builder(wireMockRule, true).WithMinimalDefaults(); jenkins.setSecurityRealm(builder.build()); - assertThrows(SSLException.class, () -> browseLoginPage()); + assertThrows(SSLException.class, () -> TestHelper.browseLoginPage(webClient, jenkins)); } @Test public void testLoginWithDefaultsUntrustedTLSPassesWhenTLSChecksDisabled() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); TestRealm.Builder builder = new TestRealm.Builder(wireMockRule, true).WithMinimalDefaults().WithDisableSslVerification(true); jenkins.setSecurityRealm(builder.build()); // webclient talks to the OP via SSL so we need to disable Webclients TLS validation also webClient.getOptions().setUseInsecureSSL(true); - browseLoginPage(); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfTestGroups(user); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfTestGroups(user); } @Test @@ -188,12 +153,12 @@ public void testSessionRefresh() throws Exception { CookieManager cookieManager = webClient.getCookieManager(); Cookie jSessionIDCookie = new Cookie(cookieHost, cookieName, previousSession, cookiePath, null, false, true); - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - configureTestRealm(sc -> {}); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + TestHelper.configureTestRealm(wireMockRule, jenkins, sc -> {}); // Not yet logged in - assertAnonymous(); + TestHelper.assertAnonymous(webClient); assertEquals( "No session cookie should be present", 0, @@ -204,7 +169,7 @@ public void testSessionRefresh() throws Exception { // Set a JSESSIONID cookie value before the first login is attempted. cookieManager.addCookie(jSessionIDCookie); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); // Multiple JSESSIONID can exist if, for example, the path is different assertEquals( @@ -217,60 +182,19 @@ public void testSessionRefresh() throws Exception { String firstLoginSession = cookieManager.getCookie(cookieName).getValue(); assertNotEquals("The previous session should be replaced with a new one", previousSession, firstLoginSession); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); String secondLoginSession = cookieManager.getCookie(cookieName).getValue(); assertNotEquals("The session should be renewed when the user log in", firstLoginSession, secondLoginSession); } - private void browseLoginPage() throws IOException, SAXException { - webClient.goTo(jenkins.getSecurityRealm().getLoginUrl()); - } - - private void configureTestRealm(@NonNull Consumer consumer) throws Exception { - var securityRealm = new TestRealm(wireMockRule); - consumer.accept(securityRealm); - jenkins.setSecurityRealm(securityRealm); - } - - private static void assertTestUserIsMemberOfTestGroups(User user) { - assertTestUserIsMemberOfGroups(user, TEST_USER_GROUPS); - } - - private static void assertTestUserIsMemberOfGroups(User user, String... testUserGroups) { - for (String group : testUserGroups) { - assertTrue( - "User should be part of group " + group, - user.getAuthorities().contains(group)); - } - } - - private void assertAnonymous() { - assertEquals( - "Shouldn't be authenticated", - Jenkins.ANONYMOUS2.getPrincipal(), - getAuthentication().getPrincipal()); - } - - private void mockAuthorizationRedirectsToFinishLogin() { - wireMockRule.stubFor(get(urlPathEqualTo("/authorization")) - .willReturn(aResponse() - .withTransformers("response-template") - .withStatus(302) - .withHeader("Content-Type", "text/html; charset=utf-8") - .withHeader( - "Location", - jenkins.getRootUrl() - + "securityRealm/finishLogin?state={{request.query.state}}&code=code"))); - } - @Test @Ignore("there is no configuration option for this and the spec does not have scopes in a token endpoint") public void testLoginWithScopesInTokenRequest() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - configureTestRealm(sc -> sc.setSendScopesInTokenRequest(true)); - browseLoginPage(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + TestHelper.configureTestRealm(wireMockRule, jenkins, sc -> sc.setSendScopesInTokenRequest(true)); + TestHelper.browseLoginPage(webClient, jenkins); verify(getRequestedFor(urlPathEqualTo("/authorization")).withQueryParam("scope", equalTo("openid email"))); verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(containing("&scope=openid+email&"))); @@ -278,11 +202,11 @@ public void testLoginWithScopesInTokenRequest() throws Exception { @Test public void testLoginWithPkceEnabled() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); - configureTestRealm(sc -> sc.setPkceEnabled(true)); - browseLoginPage(); + TestHelper.configureTestRealm(wireMockRule, jenkins, sc -> sc.setPkceEnabled(true)); + TestHelper.browseLoginPage(webClient, jenkins); verify(getRequestedFor(urlPathEqualTo("/authorization")) .withQueryParam("code_challenge_method", equalTo("S256")) @@ -316,29 +240,29 @@ public void testLoginWithPkceEnabled() throws Exception { @Test public void testLoginWithNonceDisabled() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - configureTestRealm(sc -> sc.setNonceDisabled(true)); - browseLoginPage(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + TestHelper.configureTestRealm(wireMockRule, jenkins, sc -> sc.setNonceDisabled(true)); + TestHelper.browseLoginPage(webClient, jenkins); verify(getRequestedFor(urlPathEqualTo("/authorization")).withQueryParam("nonce", absent())); } @Test public void testLoginUsingUserInfoEndpointWithGroupsMap() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfoWithGroups(TEST_USER_GROUPS_MAP); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfoWithGroups(wireMockRule, TEST_USER_GROUPS_MAP); - System.out.println("jsonarray : " + toJson(TEST_USER_GROUPS_MAP)); + System.out.println("jsonarray : " + TestHelper.toJson(TEST_USER_GROUPS_MAP)); jenkins.setSecurityRealm(new TestRealm( wireMockRule, "http://localhost:" + wireMockRule.port() + "/userinfo", "email", "groups[].name")); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - var user = assertTestUser(); - assertTestUserEmail(user); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); for (Map group : TEST_USER_GROUPS_MAP) { var groupName = group.get("name"); assertTrue( @@ -349,51 +273,51 @@ public void testLoginUsingUserInfoEndpointWithGroupsMap() throws Exception { @Test public void testLoginWithMinimalConfiguration() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, null, null)); - assertAnonymous(); - browseLoginPage(); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); - var user = assertTestUser(); + var user = TestHelper.assertTestUser(webClient); assertTrue( "User should be not be part of any group", user.getAuthorities().isEmpty()); } @Test public void testLoginWithAutoConfiguration() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - mockUserInfoWithTestGroups(); - configureWellKnown(null, null); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.configureWellKnown(wireMockRule, null, null); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); - assertAnonymous(); - browseLoginPage(); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfTestGroups(user); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfTestGroups(user); } @Test public void testLoginWithAutoConfiguration_WithNoScope() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithValues(setUpKeyValuesNoGroup()); - mockUserInfoWithGroups(null); - configureWellKnown(null, null); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithValues(wireMockRule, TestHelper.setUpKeyValuesNoGroup()); + Mocks.mockUserInfoWithGroups(wireMockRule, null); + TestHelper.configureWellKnown(wireMockRule, null, null); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); - assertAnonymous(); - configureWellKnown(null, null); + TestHelper.assertAnonymous(webClient); + TestHelper.configureWellKnown(wireMockRule, null, null); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); - assertAnonymous(); - browseLoginPage(); - var user = assertTestUser(); - assertTestUserEmail(user); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); assertThat("User should be not be part of any group", user.getAuthorities(), empty()); } @Test public void testConfigurationWithAutoConfiguration_withScopeOverride() throws Exception { - configureWellKnown(null, List.of("openid", "profile", "scope1", "scope2", "scope3")); + TestHelper.configureWellKnown(wireMockRule, null, List.of("openid", "profile", "scope1", "scope2", "scope3")); TestRealm oicsr = new TestRealm.Builder(wireMockRule) .WithMinimalDefaults().WithAutomanualconfigure(true).build(); jenkins.setSecurityRealm(oicsr); @@ -420,221 +344,30 @@ public void testConfigurationWithAutoConfiguration_withScopeOverride() throws Ex serverConfig.toProviderMetadata().getScopes()); } - @Test - public void testConfigurationWithAutoConfiguration_withRefreshToken() throws Exception { - configureWellKnown(null, null, "authorization_code", "refresh_token"); - TestRealm oicsr = new TestRealm.Builder(wireMockRule) - .WithMinimalDefaults().WithAutomanualconfigure(true).build(); - jenkins.setSecurityRealm(oicsr); - assertTrue( - "Refresh token should be enabled", - oicsr.getServerConfiguration() - .toProviderMetadata() - .getGrantTypes() - .contains(GrantType.REFRESH_TOKEN)); - } - - @Test - public void testRefreshToken_validAndExtendedToken() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - configureWellKnown(null, null, "authorization_code", "refresh_token"); - jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); - // user groups on first login - mockTokenReturnsIdTokenWithGroup(); - mockUserInfoWithTestGroups(); - browseLoginPage(); - var user = assertTestUser(); - assertFalse( - "User should not be part of group " + TEST_USER_GROUPS_REFRESHED[2], - user.getAuthorities().contains(TEST_USER_GROUPS_REFRESHED[2])); - - // refresh user with different groups - mockTokenReturnsIdTokenWithValues(setUpKeyValuesWithGroup(TEST_USER_GROUPS_REFRESHED)); - mockUserInfoWithGroups(TEST_USER_GROUPS_REFRESHED); - expire(); - webClient.goTo(jenkins.getSearchUrl()); - - user = assertTestUser(); - assertTrue( - "User should be part of group " + TEST_USER_GROUPS_REFRESHED[2], - user.getAuthorities().contains(TEST_USER_GROUPS_REFRESHED[2])); - - verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(containing("grant_type=refresh_token"))); - } - - private HttpResponse getPageWithGet(String url) throws IOException, InterruptedException { - // fix up the url, if needed - if (url.startsWith("/")) { - url = url.substring(1); - } - - HttpClient c = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NEVER) - .build(); - return c.send( - HttpRequest.newBuilder(URI.create(jenkinsRule.getURL() + url)) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - } - - /** - * performs a GET request using a basic authorization header - * @param user - The user id - * @param token - the password api token to user - * @param url - the url to request - * @return HttpResponse - * @throws IOException - * @throws InterruptedException - */ - private HttpResponse getPageWithGet(String user, String token, String url) - throws IOException, InterruptedException { - // fix up the url, if needed - if (url.startsWith("/")) { - url = url.substring(1); - } - - HttpClient c = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.ALWAYS) - .build(); - return c.send( - HttpRequest.newBuilder(URI.create(jenkinsRule.getURL() + url)) - .header( - "Authorization", - "Basic " - + Base64.getEncoder() - .encodeToString((user + ":" + token).getBytes(StandardCharsets.UTF_8))) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - } - - @Test - public void testRefreshTokenAndTokenExpiration_withoutRefreshToken() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - configureWellKnown(null, null, "authorization_code"); - jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); - // login - mockTokenReturnsIdTokenWithGroup(PluginTest::withoutRefreshToken); - mockUserInfoWithTestGroups(); - browseLoginPage(); - assertTestUser(); - // expired token not refreshed - expire(); - // use an actual HttpClient to make checking redirects easier - HttpResponse rsp = getPageWithGet("/manage"); - MatcherAssert.assertThat("response should have been 302\n" + rsp.body(), rsp.statusCode(), is(302)); - verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(notMatching(".*grant_type=refresh_token.*"))); - } - - @Test - public void testRefreshTokenWithTokenExpirationCheckDisabled_withoutRefreshToken() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - configureWellKnown(null, null, "authorization_code"); - var realm = new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true); - realm.setTokenExpirationCheckDisabled(true); - jenkins.setSecurityRealm(realm); - // login - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfoWithTestGroups(); - browseLoginPage(); - assertTestUser(); - - expire(); - webClient.goTo(jenkins.getSearchUrl()); - - verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(notMatching(".*grant_type=refresh_token.*"))); - } - - @Test - public void testRefreshTokenWithTokenExpirationCheckDisabled_expiredRefreshToken() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - configureWellKnown(null, null, "authorization_code", "refresh_token"); - TestRealm testRealm = new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true); - testRealm.setTokenExpirationCheckDisabled(true); - jenkins.setSecurityRealm(testRealm); - // login - mockTokenReturnsIdTokenWithGroup(); - mockUserInfoWithTestGroups(); - browseLoginPage(); - assertTestUser(); - - wireMockRule.stubFor(post(urlPathEqualTo("/token")) - .willReturn(aResponse() - .withStatus(400) - .withHeader("Content-Type", "application/json") - .withBody("{ \"error\": \"invalid_grant\" }"))); - expire(); - webClient.goTo(jenkins.getSearchUrl(), ""); - - verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(containing("grant_type=refresh_token"))); - } - - @Test - public void testRefreshTokenAndTokenExpiration_expiredRefreshToken() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - configureWellKnown(null, null, "authorization_code", "refresh_token"); - TestRealm testRealm = new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true); - jenkins.setSecurityRealm(testRealm); - // login - mockTokenReturnsIdTokenWithGroup(); - mockUserInfoWithTestGroups(); - browseLoginPage(); - assertTestUser(); - - wireMockRule.stubFor(post(urlPathEqualTo("/token")) - .willReturn(aResponse() - .withStatus(400) - .withHeader("Content-Type", "application/json") - .withBody("{ \"error\": \"invalid_grant\" }"))); - expire(); - webClient.assertFails(jenkins.getSearchUrl(), 500); - - verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(containing("grant_type=refresh_token"))); - } - @Test public void testTokenExpiration_withoutExpiresInValue() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - configureWellKnown(null, null, "authorization_code", "refresh_token"); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + TestHelper.configureWellKnown(wireMockRule, null, null, "authorization_code", "refresh_token"); TestRealm testRealm = new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true); jenkins.setSecurityRealm(testRealm); // login - mockTokenReturnsIdTokenWithGroup(PluginTest::withoutExpiresIn); - mockUserInfoWithTestGroups(); - browseLoginPage(); - var user = assertTestUser(); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule, TestHelper::withoutExpiresIn); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); OicCredentials credentials = user.getProperty(OicCredentials.class); assertNotNull(credentials); assertNull(credentials.getExpiresAtMillis()); } - private void expire() throws Exception { - webClient.executeOnServer(() -> { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - User user = User.get2(authentication); - OicCredentials credentials = user.getProperty(OicCredentials.class); - - // setting currentTimestamp == 1 guarantees this will be an expired cred - user.addProperty(new OicCredentials( - credentials.getAccessToken(), - credentials.getIdToken(), - credentials.getRefreshToken(), - 60L, - 1L, - 60L)); - return null; - }); - } - @Test - public void testreadResolve_withNulls() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithValues(setUpKeyValuesWithGroup()); - mockUserInfoWithTestGroups(); + public void testReadResolve_withNulls() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithValues(wireMockRule, TestHelper.setUpKeyValuesWithGroup()); + Mocks.mockUserInfoWithTestGroups(wireMockRule); - configureWellKnown(null, null); + TestHelper.configureWellKnown(wireMockRule, null, null); TestRealm realm = new TestRealm(wireMockRule, null, null, null, true); jenkins.setSecurityRealm(realm); @@ -643,11 +376,11 @@ public void testreadResolve_withNulls() throws Exception { } @Test - public void testreadResolve_withNonNulls() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - mockUserInfoWithTestGroups(); - configureWellKnown("http://localhost/endSession", null); + public void testReadResolve_withNonNulls() throws Exception { + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); + TestHelper.configureWellKnown(wireMockRule, "http://localhost/endSession", null); TestRealm realm = new TestRealm(wireMockRule, null, null, null, true); jenkins.setSecurityRealm(realm); assertEquals(realm, realm.readResolve()); @@ -655,47 +388,47 @@ public void testreadResolve_withNonNulls() throws Exception { @Test public void testLoginUsingUserInfoEndpoint() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfoWithTestGroups(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfoWithTestGroups(wireMockRule); jenkins.setSecurityRealm(new TestRealm(wireMockRule, "http://localhost:" + wireMockRule.port() + "/userinfo")); - assertAnonymous(); - browseLoginPage(); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfTestGroups(user); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfTestGroups(user); } @Test public void testLoginUsingUserInfoWithJWT() throws Exception { - KeyPair keyPair = createKeyPair(); - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfoJwtWithTestGroups(keyPair, "group1"); + KeyPair keyPair = TestHelper.createKeyPair(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfoJwtWithTestGroups(wireMockRule, keyPair, "group1"); jenkins.setSecurityRealm(new TestRealm(wireMockRule, "http://localhost:" + wireMockRule.port() + "/userinfo")); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfGroups(user, "group1"); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfGroups(user, "group1"); } @Test public void testLoginWithJWTSignature() throws Exception { - KeyPair keyPair = createKeyPair(); + KeyPair keyPair = TestHelper.createKeyPair(); wireMockRule.stubFor(get(urlPathEqualTo("/jwks")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") - .withBody("{\"keys\":[{" + encodePublicKey(keyPair) + ",\"use\":\"sig\",\"kid\":\"jwks_key_id\"" - + "}]}"))); - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(keyPair); - mockUserInfoJwtWithTestGroups(keyPair, TEST_USER_GROUPS); + .withBody("{\"keys\":[{" + TestHelper.encodePublicKey(keyPair) + + ",\"use\":\"sig\",\"kid\":\"jwks_key_id\"" + "}]}"))); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule, keyPair); + Mocks.mockUserInfoJwtWithTestGroups(wireMockRule, keyPair, TestHelper.TEST_USER_GROUPS); jenkins.setSecurityRealm(new TestRealm.Builder(wireMockRule) .WithUserInfoServerUrl("http://localhost:" + wireMockRule.port() + "/userinfo") @@ -703,114 +436,127 @@ public void testLoginWithJWTSignature() throws Exception { .WithDisableTokenValidation(false) .build()); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - Authentication authentication = getAuthentication(); - assertEquals("Should be logged-in as " + TEST_USER_USERNAME, TEST_USER_USERNAME, authentication.getPrincipal()); + Object principal = TestHelper.getPrincipal(webClient); + assertEquals( + "Should be logged-in as " + TestHelper.TEST_USER_USERNAME, TestHelper.TEST_USER_USERNAME, principal); } @Test @Ignore("never enabled, fails because of https://github.com/jenkinsci/oic-auth-plugin/pull/308") public void testLoginWithWrongJWTSignature() throws Exception { - KeyPair keyPair = createKeyPair(); + KeyPair keyPair = TestHelper.createKeyPair(); wireMockRule.stubFor(get(urlPathEqualTo("/jwks")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") - .withBody("{\"keys\":[{" + encodePublicKey(keyPair) + .withBody("{\"keys\":[{" + TestHelper.encodePublicKey(keyPair) + ",\"use\":\"sig\",\"kid\":\"wrong_key_id\"" + "}]}"))); - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(keyPair); - mockUserInfoJwtWithTestGroups(keyPair, TEST_USER_GROUPS); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule, keyPair); + Mocks.mockUserInfoJwtWithTestGroups(wireMockRule, keyPair, TestHelper.TEST_USER_GROUPS); TestRealm testRealm = new TestRealm.Builder(wireMockRule) .WithUserInfoServerUrl("http://localhost:" + wireMockRule.port() + "/userinfo") .WithJwksServerUrl("http://localhost:" + wireMockRule.port() + "/jwks") .build(); jenkins.setSecurityRealm(testRealm); - assertAnonymous(); - browseLoginPage(); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertAnonymous(webClient); testRealm.setDisableTokenVerification(true); - browseLoginPage(); - Authentication authentication = getAuthentication(); - assertEquals("Should be logged-in as " + TEST_USER_USERNAME, TEST_USER_USERNAME, authentication.getPrincipal()); + TestHelper.browseLoginPage(webClient, jenkins); + Object principal = TestHelper.getPrincipal(webClient); + assertEquals( + "Should be logged-in as " + TestHelper.TEST_USER_USERNAME, TestHelper.TEST_USER_USERNAME, principal); } @Test public void testShouldLogUserWithoutGroupsWhenUserGroupIsMissing() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfoWithGroups(null); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfoWithGroups(wireMockRule, null); jenkins.setSecurityRealm(new TestRealm(wireMockRule, "http://localhost:" + wireMockRule.port() + "/userinfo")); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - User user = toUser(getAuthentication()); + Authentication authentication = TestHelper.getAuthentication(webClient); + assertNotNull("Authentication should not be null", authentication); + User user = TestHelper.toUser(authentication); + assertNotNull("User should not be null", user); assertTrue("User shouldn't be part of any group", user.getAuthorities().isEmpty()); } @Test public void testShouldLogUserWithoutGroupsWhenUserGroupIsNull() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfoWithGroups(JsonNull.INSTANCE); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfoWithGroups(wireMockRule, JsonNull.INSTANCE); jenkins.setSecurityRealm(new TestRealm(wireMockRule, "http://localhost:" + wireMockRule.port() + "/userinfo")); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - User user = toUser(getAuthentication()); + Authentication authentication = TestHelper.getAuthentication(webClient); + assertNotNull("Authentication should not be null", authentication); + User user = TestHelper.toUser(authentication); + assertNotNull("User should not be null", user); assertTrue("User shouldn't be part of any group", user.getAuthorities().isEmpty()); } @Test public void testShouldLogUserWithoutGroupsWhenUserGroupIsNotAStringList() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfoWithGroups(Map.of("not", "a group")); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfoWithGroups(wireMockRule, Map.of("not", "a group")); jenkins.setSecurityRealm(new TestRealm(wireMockRule, "http://localhost:" + wireMockRule.port() + "/userinfo")); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - User user = toUser(getAuthentication()); + Authentication authentication = TestHelper.getAuthentication(webClient); + assertNotNull("Authentication should not be null", authentication); + User user = TestHelper.toUser(authentication); + assertNotNull("User should not be null", user); assertTrue("User shouldn't be part of any group", user.getAuthorities().isEmpty()); } @Test public void testNestedFieldLookup() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithValues(setUpKeyValuesNested()); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithValues(wireMockRule, TestHelper.setUpKeyValuesNested()); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, "nested.email", "nested.groups")); - assertAnonymous(); - browseLoginPage(); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfTestGroups(user); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfTestGroups(user); } @Test public void testNestedFieldLookupFromUserInfoEndpoint() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfo(Map.of( - "sub", - TEST_USER_USERNAME, - FULL_NAME_FIELD, - TEST_USER_FULL_NAME, - "nested", - Map.of("email", TEST_USER_EMAIL_ADDRESS, "groups", TEST_USER_GROUPS), - EMAIL_FIELD, - "")); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfo( + wireMockRule, + Map.of( + "sub", + TestHelper.TEST_USER_USERNAME, + FULL_NAME_FIELD, + TestHelper.TEST_USER_FULL_NAME, + "nested", + Map.of("email", TestHelper.TEST_USER_EMAIL_ADDRESS, "groups", TestHelper.TEST_USER_GROUPS), + EMAIL_FIELD, + "")); jenkins.setSecurityRealm(new TestRealm( wireMockRule, @@ -818,68 +564,64 @@ public void testNestedFieldLookupFromUserInfoEndpoint() throws Exception { "nested.email", "nested.groups")); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfTestGroups(user); - } - - private static void assertTestUserEmail(User user) { - assertEquals( - "Email should be " + TEST_USER_EMAIL_ADDRESS, - TEST_USER_EMAIL_ADDRESS, - user.getProperty(Mailer.UserProperty.class).getAddress()); - } - - private @NonNull User assertTestUser() { - Authentication authentication = getAuthentication(); - assertEquals("Should be logged-in as " + TEST_USER_USERNAME, TEST_USER_USERNAME, authentication.getPrincipal()); - User user = toUser(authentication); - assertEquals("Full name should be " + TEST_USER_FULL_NAME, TEST_USER_FULL_NAME, user.getFullName()); - return user; + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfTestGroups(user); } @Test public void testFieldLookupFromIdTokenWhenNotInUserInfoEndpoint() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); - mockTokenReturnsIdTokenWithValues(setUpKeyValuesWithGroupAndSub()); - mockUserInfo(Map.of("sub", "", FULL_NAME_FIELD, JsonNull.INSTANCE, GROUPS_FIELD, TEST_USER_GROUPS)); + Mocks.mockTokenReturnsIdTokenWithValues(wireMockRule, TestHelper.setUpKeyValuesWithGroupAndSub()); + Mocks.mockUserInfo( + wireMockRule, + Map.of("sub", "", FULL_NAME_FIELD, JsonNull.INSTANCE, GROUPS_FIELD, TestHelper.TEST_USER_GROUPS)); jenkins.setSecurityRealm(new TestRealm( wireMockRule, "http://localhost:" + wireMockRule.port() + "/userinfo", "email", "groups")); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - Authentication authentication = getAuthentication(); assertEquals( "Should read field (ex:username) from IdToken when empty in userInfo", - TEST_USER_USERNAME, - authentication.getPrincipal()); - User user = toUser(authentication); + TestHelper.TEST_USER_USERNAME, + TestHelper.getPrincipal(webClient)); + + Authentication authentication = TestHelper.getAuthentication(webClient); + assertNotNull("Authentication should not be null", authentication); + User user = TestHelper.toUser(authentication); + assertNotNull("User should not be null", user); assertEquals( "Should read field (ex:full name) from IdToken when null in userInfo", - TEST_USER_FULL_NAME, + TestHelper.TEST_USER_FULL_NAME, user.getFullName()); assertEquals( "Should read field (ex:email) from IdToken when not in userInfo", - TEST_USER_EMAIL_ADDRESS, + TestHelper.TEST_USER_EMAIL_ADDRESS, user.getProperty(Mailer.UserProperty.class).getAddress()); } @Test public void testGroupListFromStringInfoEndpoint() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithoutValues(); - mockUserInfo(Map.of( - "sub", - TEST_USER_USERNAME, - FULL_NAME_FIELD, - TEST_USER_FULL_NAME, - "nested", - Map.of(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS, GROUPS_FIELD, TEST_USER_GROUPS))); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithoutValues(wireMockRule); + Mocks.mockUserInfo( + wireMockRule, + Map.of( + "sub", + TestHelper.TEST_USER_USERNAME, + FULL_NAME_FIELD, + TestHelper.TEST_USER_FULL_NAME, + "nested", + Map.of( + EMAIL_FIELD, + TestHelper.TEST_USER_EMAIL_ADDRESS, + GROUPS_FIELD, + TestHelper.TEST_USER_GROUPS))); jenkins.setSecurityRealm(new TestRealm( wireMockRule, @@ -887,31 +629,31 @@ public void testGroupListFromStringInfoEndpoint() throws Exception { "nested.email", "nested.groups")); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - var user = assertTestUser(); - assertTestUserEmail(user); - assertTestUserIsMemberOfTestGroups(user); + var user = TestHelper.assertTestUser(webClient); + TestHelper.assertTestUserEmail(user); + TestHelper.assertTestUserIsMemberOfTestGroups(user); assertEquals("User should be in 2 groups", 2, user.getAuthorities().size()); } @Test public void testLastGrantedAuthoritiesProperty() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); - mockTokenReturnsIdTokenWithValues(setUpKeyValuesWithGroup()); + Mocks.mockTokenReturnsIdTokenWithValues(wireMockRule, TestHelper.setUpKeyValuesWithGroup()); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, false)); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); - browseLoginPage(); + TestHelper.browseLoginPage(webClient, jenkins); - var user = assertTestUser(); + var user = TestHelper.assertTestUser(webClient); - assertTestUserEmail(user); + TestHelper.assertTestUserEmail(user); assertEquals("User should be in 2 groups", 2, user.getAuthorities().size()); LastGrantedAuthoritiesProperty userProperty = user.getProperty(LastGrantedAuthoritiesProperty.class); @@ -920,11 +662,14 @@ public void testLastGrantedAuthoritiesProperty() throws Exception { 3, userProperty.getAuthorities2().size()); - HtmlPage configure = Jenkins.getVersion().isNewerThan(new VersionNumber("2.467")) + VersionNumber version = Jenkins.getVersion(); + assertNotNull("Jenkins version must not be null", version); + HtmlPage configure = version.isNewerThan(new VersionNumber("2.467")) ? webClient.goTo("me/account/") : webClient.goTo("me/configure"); jenkinsRule.submit(configure.getFormByName("config")); - user = User.getById(TEST_USER_USERNAME, false); + user = User.getById(TestHelper.TEST_USER_USERNAME, false); + assertNotNull("User should not be null", user); assertEquals( "User should still be in 2 groups", 2, user.getAuthorities().size()); userProperty = user.getProperty(LastGrantedAuthoritiesProperty.class); @@ -934,48 +679,6 @@ public void testLastGrantedAuthoritiesProperty() throws Exception { userProperty.getAuthorities2().size()); } - private void configureWellKnown(@CheckForNull String endSessionUrl, @CheckForNull List scopesSupported) { - configureWellKnown(endSessionUrl, scopesSupported, "authorization_code"); - } - - private void configureWellKnown( - @CheckForNull String endSessionUrl, - @CheckForNull List scopesSupported, - @CheckForNull String... grantTypesSupported) { - // scopes_supported may not be null, but is not required to be present. - // if present it must minimally be "openid" - // Claims with zero elements MUST be omitted from the response. - - Map values = new HashMap<>(); - values.putAll(Map.of( - "authorization_endpoint", - "http://localhost:" + wireMockRule.port() + "/authorization", - "token_endpoint", - "http://localhost:" + wireMockRule.port() + "/token", - "userinfo_endpoint", - "http://localhost:" + wireMockRule.port() + "/userinfo", - "jwks_uri", - "http://localhost:" + wireMockRule.port() + "/jwks", - "issuer", - TestRealm.ISSUER, - "subject_types_supported", - List.of("public"))); - if (scopesSupported != null && !scopesSupported.isEmpty()) { - values.put("scopes_supported", scopesSupported); - } - if (endSessionUrl != null) { - values.put("end_session_endpoint", endSessionUrl); - } - if (grantTypesSupported.length != 0) { - values.put("grant_types_supported", grantTypesSupported); - } - - wireMockRule.stubFor(get(urlPathEqualTo("/well.known")) - .willReturn(aResponse() - .withHeader("Content-Type", "text/html; charset=utf-8") - .withBody(toJson(values)))); - } - @Test public void testLogoutShouldBeJenkinsOnlyWhenNoProviderLogoutConfigured() throws Exception { final TestRealm oicsr = new TestRealm.Builder(wireMockRule).build(); @@ -1022,330 +725,70 @@ public void testLogoutShouldBeProviderURLWithRedirectWhenProviderLogoutConfigure logoutURL[0]); } - private KeyPair createKeyPair() throws NoSuchAlgorithmException { - KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); - keyGen.initialize(2048); - return keyGen.generateKeyPair(); - } - - private String createIdToken(PrivateKey privateKey, Map keyValues) throws Exception { - JsonWebSignature.Header header = - new JsonWebSignature.Header().setAlgorithm("RS256").setKeyId("jwks_key_id"); - long now = Clock.systemUTC().millis() / 1000; - IdToken.Payload payload = new IdToken.Payload() - .setExpirationTimeSeconds(now + 60L) - .setIssuedAtTimeSeconds(now) - .setIssuer(TestRealm.ISSUER) - .setSubject(TEST_USER_USERNAME) - .setAudience(Collections.singletonList(TestRealm.CLIENT_ID)) - .setNonce("nonce"); - for (Map.Entry keyValue : keyValues.entrySet()) { - payload.set(keyValue.getKey(), keyValue.getValue()); - } - - return JsonWebSignature.signUsingRsaSha256(privateKey, GsonFactory.getDefaultInstance(), header, payload); - } - - private String createUserInfoJWT(PrivateKey privateKey, String userInfo) throws Exception { - - JsonWebSignature.Header header = - new JsonWebSignature.Header().setAlgorithm("RS256").setKeyId("jwks_key_id"); - - JsonWebToken.Payload payload = new JsonWebToken.Payload(); - for (Map.Entry keyValue : - parseString(userInfo).getAsJsonObject().entrySet()) { - var value = keyValue.getValue(); - if (value.isJsonArray()) { - payload.set(keyValue.getKey(), new Gson().fromJson(value, String[].class)); - } else { - payload.set(keyValue.getKey(), value.getAsString()); - } - } - - return JsonWebSignature.signUsingRsaSha256(privateKey, GsonFactory.getDefaultInstance(), header, payload); - } - @Test public void testLoginWithMissingIdTokenShouldBeRefused() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdToken(null); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdToken(wireMockRule, null); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, null, null)); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); webClient.assertFails(jenkins.getSecurityRealm().getLoginUrl(), 500); } @Test public void testLoginWithUnreadableIdTokenShouldBeRefused() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdToken("This is not an IdToken"); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdToken(wireMockRule, "This is not an IdToken"); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, null, null)); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); webClient.assertFails(jenkins.getSecurityRealm().getLoginUrl(), 500); } @Test public void loginWithCheckTokenSuccess() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - configureTestRealm(belongsToGroup("group1")); - assertAnonymous(); - browseLoginPage(); - assertTestUser(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + TestHelper.configureTestRealm(wireMockRule, jenkins, TestHelper.belongsToGroup("group1")); + TestHelper.assertAnonymous(webClient); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertTestUser(webClient); } @Test public void loginWithCheckTokenFailure() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - configureTestRealm(belongsToGroup("missing-group")); - assertAnonymous(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); + TestHelper.configureTestRealm(wireMockRule, jenkins, TestHelper.belongsToGroup("missing-group")); + TestHelper.assertAnonymous(webClient); webClient.setThrowExceptionOnFailingStatusCode(false); - browseLoginPage(); - assertAnonymous(); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertAnonymous(webClient); } @Test @Issue("SECURITY-3441") public void loginWithIncorrectIssuerFails() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); jenkins.setSecurityRealm(new TestRealm.Builder(wireMockRule) .WithIssuer("another_issuer").WithDisableTokenValidation(false).build()); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); webClient.setThrowExceptionOnFailingStatusCode(false); - browseLoginPage(); - assertAnonymous(); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertAnonymous(webClient); } @Test @Issue("SECURITY-3441") public void loginWithIncorrectAudienceFails() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); + Mocks.mockAuthorizationRedirectsToFinishLogin(wireMockRule, jenkins); + Mocks.mockTokenReturnsIdTokenWithGroup(wireMockRule); jenkins.setSecurityRealm(new TestRealm.Builder(wireMockRule) .WithClient("another_client_id", "client_secret") .WithDisableTokenValidation(false) .build()); - assertAnonymous(); + TestHelper.assertAnonymous(webClient); webClient.setThrowExceptionOnFailingStatusCode(false); - browseLoginPage(); - assertAnonymous(); - } - - @Test - public void testAccessUsingJenkinsApiTokens() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - configureWellKnown(null, null, "authorization_code"); - jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, true)); - // explicitly ensure allowTokenAccessWithoutOicSession is disabled - TestRealm testRealm = (TestRealm) jenkins.getSecurityRealm(); - testRealm.setAllowTokenAccessWithoutOicSession(false); - - // login and assert normal auth is working - mockTokenReturnsIdTokenWithGroup(PluginTest::withoutRefreshToken); - mockUserInfoWithTestGroups(); - browseLoginPage(); - assertTestUser(); - - // create a jenkins api token for the test user - String token = User.getById(TEST_USER_USERNAME, false) - .getProperty(ApiTokenProperty.class) - .generateNewToken("foo") - .plainValue; - - // validate that the token can be used - HttpResponse rsp = getPageWithGet(TEST_USER_USERNAME, token, "/whoAmI/api/xml"); - MatcherAssert.assertThat("response should have been 200\n" + rsp.body(), rsp.statusCode(), is(200)); - - MatcherAssert.assertThat( - "response should have been 200\n" + rsp.body(), - rsp.body(), - containsString("true")); - - // expired oic session tokens, do not refreshed - expire(); - - // the default behavior expects there to be a valid oic session, so token based - // access should now fail (unauthorized) - rsp = getPageWithGet(TEST_USER_USERNAME, token, "/whoAmI/api/xml"); - MatcherAssert.assertThat("response should have been 302\n" + rsp.body(), rsp.statusCode(), is(302)); - - // enable "traditional api token access" - testRealm.setAllowTokenAccessWithoutOicSession(true); - - // verify that jenkins api token is now working again - rsp = getPageWithGet(TEST_USER_USERNAME, token, "/whoAmI/api/xml"); - MatcherAssert.assertThat("response should have been 200\n" + rsp.body(), rsp.statusCode(), is(200)); - MatcherAssert.assertThat( - "response should have been 200\n" + rsp.body(), - rsp.body(), - containsString("true")); - } - - private static @NonNull Consumer belongsToGroup(String groupName) { - return sc -> { - sc.setTokenFieldToCheckKey("contains(groups, '" + groupName + "')"); - sc.setTokenFieldToCheckValue("true"); - }; - } - - /** Generate JWKS entry with public key of keyPair */ - String encodePublicKey(KeyPair keyPair) { - final RSAPublicKey rsaPKey = (RSAPublicKey) (keyPair.getPublic()); - return "\"n\":\"" - + Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(rsaPKey.getModulus().toByteArray()) - + "\",\"e\":\"" - + Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(rsaPKey.getPublicExponent().toByteArray()) - + "\",\"alg\":\"RS256\",\"kty\":\"RSA\""; - } - - /** - * Gets the authentication object from the web client. - * - * @return the authentication object - */ - private Authentication getAuthentication() { - try { - return webClient.executeOnServer(Jenkins::getAuthentication2); - } catch (Exception e) { - // safely ignore all exceptions, the method never throws anything - return null; - } - } - - private static @NonNull Map setUpKeyValuesNoGroup() { - Map keyValues = new HashMap<>(); - keyValues.put(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS); - keyValues.put(FULL_NAME_FIELD, TEST_USER_FULL_NAME); - return keyValues; - } - - private static @NonNull Map setUpKeyValuesWithGroup(String[] groups) { - var keyValues = setUpKeyValuesNoGroup(); - keyValues.put(GROUPS_FIELD, groups); - return keyValues; - } - - private static @NonNull Map setUpKeyValuesWithGroup() { - return setUpKeyValuesWithGroup(TEST_USER_GROUPS); - } - - private static @NonNull Map setUpKeyValuesWithGroupAndSub() { - var keyValues = setUpKeyValuesWithGroup(); - keyValues.put("sub", TEST_USER_USERNAME); - return keyValues; - } - - private static @NonNull Map setUpKeyValuesNested() { - return Map.of( - "nested", - Map.of(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS, GROUPS_FIELD, TEST_USER_GROUPS), - FULL_NAME_FIELD, - TEST_USER_FULL_NAME); - } - - private void mockUserInfoWithTestGroups() { - mockUserInfoWithGroups(TEST_USER_GROUPS); - } - - private void mockUserInfoWithGroups(@Nullable Object groups) { - mockUserInfo(getUserInfo(groups)); - } - - private void mockUserInfoJwtWithTestGroups(KeyPair keyPair, Object testUserGroups) throws Exception { - wireMockRule.stubFor(get(urlPathEqualTo("/userinfo")) - .willReturn(aResponse() - .withHeader("Content-Type", "application/jwt") - .withBody(createUserInfoJWT(keyPair.getPrivate(), toJson(getUserInfo(testUserGroups)))))); - } - - private void mockUserInfo(Map userInfo) { - wireMockRule.stubFor(get(urlPathEqualTo("/userinfo")) - .willReturn(aResponse() - .withHeader("Content-Type", "application/json") - .withBody(toJson(userInfo)))); - } - - private static Map getUserInfo(@Nullable Object groups) { - Map userInfo = new HashMap<>(); - userInfo.put("sub", TEST_USER_USERNAME); - userInfo.put(FULL_NAME_FIELD, TEST_USER_FULL_NAME); - userInfo.put(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS); - if (groups != null) { - userInfo.put(GROUPS_FIELD, groups); - } - return userInfo; - } - - private static String toJson(Object o) { - return new Gson().newBuilder().serializeNulls().create().toJson(o); - } - - private void mockTokenReturnsIdTokenWithGroup() throws Exception { - mockTokenReturnsIdTokenWithValues(setUpKeyValuesWithGroup()); - } - - private void mockTokenReturnsIdTokenWithoutValues() throws Exception { - mockTokenReturnsIdTokenWithValues(Map.of()); - } - - private void mockTokenReturnsIdTokenWithoutValues(KeyPair keyPair) throws Exception { - mockTokenReturnsIdTokenWithValues(Map.of(), keyPair); - } - - private void mockTokenReturnsIdTokenWithValues(Map keyValues) throws Exception { - mockTokenReturnsIdTokenWithValues(keyValues, createKeyPair()); - } - - private void mockTokenReturnsIdTokenWithValues(Map keyValues, KeyPair keyPair) throws Exception { - mockTokenReturnsIdToken(createIdToken(keyPair.getPrivate(), keyValues)); - } - - @SafeVarargs - private void mockTokenReturnsIdTokenWithGroup(@CheckForNull Consumer>... tokenAcceptors) - throws Exception { - var keyPair = createKeyPair(); - mockTokenReturnsIdToken(createIdToken(keyPair.getPrivate(), setUpKeyValuesWithGroup()), tokenAcceptors); - } - - private void mockTokenReturnsIdToken(@CheckForNull String idToken) { - mockTokenReturnsIdToken(idToken, new Consumer[0]); - } - - @SafeVarargs - private void mockTokenReturnsIdToken( - @CheckForNull String idToken, @CheckForNull Consumer>... tokenAcceptors) { - var token = new HashMap(); - token.put("access_token", "AcCeSs_ToKeN"); - token.put("token_type", "Bearer"); - token.put("expires_in", "3600"); - token.put("refresh_token", "ReFrEsH_ToKeN"); - token.put("example_parameter", "example_value"); - if (idToken != null) { - token.put("id_token", idToken); - } - if (tokenAcceptors != null) { - Arrays.stream(tokenAcceptors).forEach(a -> a.accept(token)); - } - wireMockRule.stubFor(post(urlPathEqualTo("/token")) - .willReturn(aResponse() - .withHeader("Content-Type", "application/json") - .withBody(toJson(token)))); - } - - private static @Nullable User toUser(Authentication authentication) { - return User.get(String.valueOf(authentication.getPrincipal()), false, Map.of()); - } - - private static void withoutRefreshToken(Map token) { - token.compute("refresh_token", (o, n) -> null); - } - - private static void withoutExpiresIn(Map token) { - token.compute("expires_in", (o, n) -> null); + TestHelper.browseLoginPage(webClient, jenkins); + TestHelper.assertAnonymous(webClient); } } diff --git a/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java b/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java index 58e01908..1f925bc9 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java +++ b/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java @@ -53,6 +53,7 @@ public static class Builder { public String escapeHatchGroup = null; public boolean automanualconfigure = false; public boolean disableTokenValidation = true; // opt in for some specific tests + public boolean allowTokenAccessWithoutOicSession = false; public Builder(WireMockRule wireMockRule, boolean useTLS) throws IOException { this( @@ -112,6 +113,11 @@ public Builder WithAutomanualconfigure(boolean automanualconfigure) { return this; } + public Builder WithAllowTokenAccessWithoutOicSession(boolean allowTokenAccessWithoutOicSession) { + this.allowTokenAccessWithoutOicSession = allowTokenAccessWithoutOicSession; + return this; + } + public Builder WithScopes(String scopes) { this.scopes = scopes; return this; @@ -198,6 +204,7 @@ public TestRealm(Builder builder) throws Exception { this.setEscapeHatchSecret(builder.escapeHatchSecret); this.setEscapeHatchGroup(builder.escapeHatchGroup); this.setDisableTokenVerification(builder.disableTokenValidation); + this.setAllowTokenAccessWithoutOicSession(builder.allowTokenAccessWithoutOicSession); // need to call the following method annotated with @PostConstruct and called // from readResolve and as such // is only called in regular use not code use. diff --git a/src/test/java/org/jenkinsci/plugins/oic/plugintest/Mocks.java b/src/test/java/org/jenkinsci/plugins/oic/plugintest/Mocks.java new file mode 100644 index 00000000..d8bbe444 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/oic/plugintest/Mocks.java @@ -0,0 +1,115 @@ +package org.jenkinsci.plugins.oic.plugintest; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.security.KeyPair; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import jenkins.model.Jenkins; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +public class Mocks { + + public static void mockAuthorizationRedirectsToFinishLogin(WireMockRule wireMockRule, Jenkins jenkins) { + wireMockRule.stubFor(get(urlPathEqualTo("/authorization")) + .willReturn(aResponse() + .withTransformers("response-template") + .withStatus(302) + .withHeader("Content-Type", "text/html; charset=utf-8") + .withHeader( + "Location", + jenkins.getRootUrl() + + "securityRealm/finishLogin?state={{request.query.state}}&code=code"))); + } + + @SafeVarargs + public static void mockTokenReturnsIdTokenWithGroup( + WireMockRule wireMockRule, @CheckForNull Consumer>... tokenAcceptors) throws Exception { + var keyPair = TestHelper.createKeyPair(); + mockTokenReturnsIdToken( + wireMockRule, + TestHelper.createIdToken(keyPair.getPrivate(), TestHelper.setUpKeyValuesWithGroup()), + tokenAcceptors); + } + + public static void mockTokenReturnsIdToken(WireMockRule wireMockRule, @CheckForNull String idToken) { + mockTokenReturnsIdToken(wireMockRule, idToken, new Consumer[0]); + } + + @SafeVarargs + public static void mockTokenReturnsIdToken( + WireMockRule wireMockRule, + @CheckForNull String idToken, + @CheckForNull Consumer>... tokenAcceptors) { + var token = new HashMap(); + token.put("access_token", "AcCeSs_ToKeN"); + token.put("token_type", "Bearer"); + token.put("expires_in", "3600"); + token.put("refresh_token", "ReFrEsH_ToKeN"); + token.put("example_parameter", "example_value"); + if (idToken != null) { + token.put("id_token", idToken); + } + if (tokenAcceptors != null) { + Arrays.stream(tokenAcceptors).forEach(a -> a.accept(token)); + } + wireMockRule.stubFor(post(urlPathEqualTo("/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(TestHelper.toJson(token)))); + } + + public static void mockTokenReturnsIdTokenWithGroup(WireMockRule wireMockRule) throws Exception { + mockTokenReturnsIdTokenWithValues(wireMockRule, TestHelper.setUpKeyValuesWithGroup()); + } + + public static void mockUserInfoWithTestGroups(WireMockRule wireMockRule) { + mockUserInfoWithGroups(wireMockRule, TestHelper.TEST_USER_GROUPS); + } + + public static void mockUserInfoWithGroups(WireMockRule wireMockRule, @Nullable Object groups) { + mockUserInfo(wireMockRule, TestHelper.getUserInfo(groups)); + } + + public static void mockUserInfoJwtWithTestGroups(WireMockRule wireMockRule, KeyPair keyPair, Object testUserGroups) + throws Exception { + wireMockRule.stubFor(get(urlPathEqualTo("/userinfo")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/jwt") + .withBody(TestHelper.createUserInfoJWT( + keyPair.getPrivate(), TestHelper.toJson(TestHelper.getUserInfo(testUserGroups)))))); + } + + public static void mockUserInfo(WireMockRule wireMockRule, Map userInfo) { + wireMockRule.stubFor(get(urlPathEqualTo("/userinfo")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(TestHelper.toJson(userInfo)))); + } + + public static void mockTokenReturnsIdTokenWithoutValues(WireMockRule wireMockRule) throws Exception { + mockTokenReturnsIdTokenWithValues(wireMockRule, Map.of()); + } + + public static void mockTokenReturnsIdTokenWithoutValues(WireMockRule wireMockRule, KeyPair keyPair) + throws Exception { + mockTokenReturnsIdTokenWithValues(wireMockRule, Map.of(), keyPair); + } + + public static void mockTokenReturnsIdTokenWithValues(WireMockRule wireMockRule, Map keyValues) + throws Exception { + mockTokenReturnsIdTokenWithValues(wireMockRule, keyValues, TestHelper.createKeyPair()); + } + + public static void mockTokenReturnsIdTokenWithValues( + WireMockRule wireMockRule, Map keyValues, KeyPair keyPair) throws Exception { + mockTokenReturnsIdToken(wireMockRule, TestHelper.createIdToken(keyPair.getPrivate(), keyValues)); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/oic/plugintest/TestHelper.java b/src/test/java/org/jenkinsci/plugins/oic/plugintest/TestHelper.java new file mode 100644 index 00000000..e296caae --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/oic/plugintest/TestHelper.java @@ -0,0 +1,369 @@ +package org.jenkinsci.plugins.oic.plugintest; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import com.google.api.client.auth.openidconnect.IdToken; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.json.webtoken.JsonWebToken; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.model.User; +import hudson.tasks.Mailer; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Clock; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import jenkins.model.Jenkins; +import org.jenkinsci.plugins.oic.OicCredentials; +import org.jenkinsci.plugins.oic.OicSecurityRealm; +import org.jenkinsci.plugins.oic.TestRealm; +import org.jvnet.hudson.test.JenkinsRule; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.xml.sax.SAXException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.google.gson.JsonParser.parseString; +import static org.jenkinsci.plugins.oic.TestRealm.EMAIL_FIELD; +import static org.jenkinsci.plugins.oic.TestRealm.FULL_NAME_FIELD; +import static org.jenkinsci.plugins.oic.TestRealm.GROUPS_FIELD; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class TestHelper { + + public static final String TEST_USER_EMAIL_ADDRESS = "test@jenkins.oic"; + public static final String TEST_USER_FULL_NAME = "Oic Test User"; + public static final String[] TEST_USER_GROUPS = new String[] {"group1", "group2"}; + public static final String TEST_USER_USERNAME = "testUser"; + + public static void configureWellKnown( + WireMockRule wireMockRule, @CheckForNull String endSessionUrl, @CheckForNull List scopesSupported) { + configureWellKnown(wireMockRule, endSessionUrl, scopesSupported, "authorization_code"); + } + + public static void configureWellKnown( + WireMockRule wireMockRule, + @CheckForNull String endSessionUrl, + @CheckForNull List scopesSupported, + @CheckForNull String... grantTypesSupported) { + // scopes_supported may not be null, but is not required to be present. + // if present it must minimally be "openid" + // Claims with zero elements MUST be omitted from the response. + + Map values = new HashMap<>(Map.of( + "authorization_endpoint", + "http://localhost:" + wireMockRule.port() + "/authorization", + "token_endpoint", + "http://localhost:" + wireMockRule.port() + "/token", + "userinfo_endpoint", + "http://localhost:" + wireMockRule.port() + "/userinfo", + "jwks_uri", + "http://localhost:" + wireMockRule.port() + "/jwks", + "issuer", + TestRealm.ISSUER, + "subject_types_supported", + List.of("public"))); + if (scopesSupported != null && !scopesSupported.isEmpty()) { + values.put("scopes_supported", scopesSupported); + } + if (endSessionUrl != null) { + values.put("end_session_endpoint", endSessionUrl); + } + if (grantTypesSupported != null && grantTypesSupported.length != 0) { + values.put("grant_types_supported", grantTypesSupported); + } + + wireMockRule.stubFor(get(urlPathEqualTo("/well.known")) + .willReturn(aResponse() + .withHeader("Content-Type", "text/html; charset=utf-8") + .withBody(toJson(values)))); + } + + public static String toJson(Object o) { + return new Gson().newBuilder().serializeNulls().create().toJson(o); + } + + public static KeyPair createKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + return keyGen.generateKeyPair(); + } + + public static @NonNull Map setUpKeyValuesWithGroup() { + return setUpKeyValuesWithGroup(TEST_USER_GROUPS); + } + + public static @NonNull Map setUpKeyValuesWithGroup(String[] groups) { + var keyValues = setUpKeyValuesNoGroup(); + keyValues.put(GROUPS_FIELD, groups); + return keyValues; + } + + public static @NonNull Map setUpKeyValuesNoGroup() { + Map keyValues = new HashMap<>(); + keyValues.put(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS); + keyValues.put(FULL_NAME_FIELD, TEST_USER_FULL_NAME); + return keyValues; + } + + public static String createIdToken(PrivateKey privateKey, Map keyValues) throws Exception { + JsonWebSignature.Header header = + new JsonWebSignature.Header().setAlgorithm("RS256").setKeyId("jwks_key_id"); + long now = Clock.systemUTC().millis() / 1000; + IdToken.Payload payload = new IdToken.Payload() + .setExpirationTimeSeconds(now + 60L) + .setIssuedAtTimeSeconds(now) + .setIssuer(TestRealm.ISSUER) + .setSubject(TEST_USER_USERNAME) + .setAudience(Collections.singletonList(TestRealm.CLIENT_ID)) + .setNonce("nonce"); + for (Map.Entry keyValue : keyValues.entrySet()) { + payload.set(keyValue.getKey(), keyValue.getValue()); + } + + return JsonWebSignature.signUsingRsaSha256(privateKey, GsonFactory.getDefaultInstance(), header, payload); + } + + public static @NonNull Consumer belongsToGroup(String groupName) { + return sc -> { + sc.setTokenFieldToCheckKey("contains(groups, '" + groupName + "')"); + sc.setTokenFieldToCheckValue("true"); + }; + } + + /** + * Generate JWKS entry with public key of keyPair + */ + public static String encodePublicKey(KeyPair keyPair) { + final RSAPublicKey rsaPKey = (RSAPublicKey) (keyPair.getPublic()); + return "\"n\":\"" + + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(rsaPKey.getModulus().toByteArray()) + + "\",\"e\":\"" + + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(rsaPKey.getPublicExponent().toByteArray()) + + "\",\"alg\":\"RS256\",\"kty\":\"RSA\""; + } + + /** + * Gets the authentication object from the web client. + * + * @return the authentication object + */ + public static Authentication getAuthentication(JenkinsRule.WebClient webClient) { + try { + return webClient.executeOnServer(Jenkins::getAuthentication2); + } catch (Exception e) { + // safely ignore all exceptions, the method never throws anything + return null; + } + } + + /** + * Gets the authentication object from the web client. + * + * @return the authentication object + */ + public static Object getPrincipal(JenkinsRule.WebClient webClient) { + try { + return webClient.executeOnServer(Jenkins::getAuthentication2).getPrincipal(); + } catch (Exception e) { + // safely ignore all exceptions, the method never throws anything + return null; + } + } + + public static @NonNull Map setUpKeyValuesWithGroupAndSub() { + var keyValues = setUpKeyValuesWithGroup(); + keyValues.put("sub", TEST_USER_USERNAME); + return keyValues; + } + + public static @NonNull Map setUpKeyValuesNested() { + return Map.of( + "nested", + Map.of(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS, GROUPS_FIELD, TEST_USER_GROUPS), + FULL_NAME_FIELD, + TEST_USER_FULL_NAME); + } + + public static Map getUserInfo(@Nullable Object groups) { + Map userInfo = new HashMap<>(); + userInfo.put("sub", TEST_USER_USERNAME); + userInfo.put(FULL_NAME_FIELD, TEST_USER_FULL_NAME); + userInfo.put(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS); + if (groups != null) { + userInfo.put(GROUPS_FIELD, groups); + } + return userInfo; + } + + public static void browseLoginPage(JenkinsRule.WebClient webClient, Jenkins jenkins) + throws IOException, SAXException { + webClient.goTo(jenkins.getSecurityRealm().getLoginUrl()); + } + + public static void configureTestRealm( + WireMockRule wireMockRule, Jenkins jenkins, @NonNull Consumer consumer) throws Exception { + var securityRealm = new TestRealm(wireMockRule); + consumer.accept(securityRealm); + jenkins.setSecurityRealm(securityRealm); + } + + public static void assertTestUserIsMemberOfTestGroups(User user) { + assertTestUserIsMemberOfGroups(user, TestHelper.TEST_USER_GROUPS); + } + + public static void assertTestUserIsMemberOfGroups(User user, String... testUserGroups) { + for (String group : testUserGroups) { + assertTrue( + "User should be part of group " + group, + user.getAuthorities().contains(group)); + } + } + + public static void assertAnonymous(JenkinsRule.WebClient webClient) { + assertEquals( + "Shouldn't be authenticated", Jenkins.ANONYMOUS2.getPrincipal(), TestHelper.getPrincipal(webClient)); + } + + public static @Nullable User toUser(Authentication authentication) { + return User.get(String.valueOf(authentication.getPrincipal()), false, Map.of()); + } + + public static void withoutRefreshToken(Map token) { + token.compute("refresh_token", (o, n) -> null); + } + + public static void withoutExpiresIn(Map token) { + token.compute("expires_in", (o, n) -> null); + } + + public static String createUserInfoJWT(PrivateKey privateKey, String userInfo) throws Exception { + + JsonWebSignature.Header header = + new JsonWebSignature.Header().setAlgorithm("RS256").setKeyId("jwks_key_id"); + + JsonWebToken.Payload payload = new JsonWebToken.Payload(); + for (Map.Entry keyValue : + parseString(userInfo).getAsJsonObject().entrySet()) { + var value = keyValue.getValue(); + if (value.isJsonArray()) { + payload.set(keyValue.getKey(), new Gson().fromJson(value, String[].class)); + } else { + payload.set(keyValue.getKey(), value.getAsString()); + } + } + + return JsonWebSignature.signUsingRsaSha256(privateKey, GsonFactory.getDefaultInstance(), header, payload); + } + + public static void assertTestUserEmail(User user) { + assertEquals( + "Email should be " + TEST_USER_EMAIL_ADDRESS, + TEST_USER_EMAIL_ADDRESS, + user.getProperty(Mailer.UserProperty.class).getAddress()); + } + + @NonNull + public static User assertTestUser(JenkinsRule.WebClient webClient) { + Authentication authentication = getAuthentication(webClient); + assertNotNull("Authentication should not be null", authentication); + assertEquals("Should be logged-in as " + TEST_USER_USERNAME, TEST_USER_USERNAME, authentication.getPrincipal()); + User user = toUser(authentication); + assertNotNull("User should not be null", user); + assertEquals("Full name should be " + TEST_USER_FULL_NAME, TEST_USER_FULL_NAME, user.getFullName()); + return user; + } + + public static HttpResponse getPageWithGet(JenkinsRule jenkinsRule, String url) + throws IOException, InterruptedException { + // fix up the url, if needed + if (url.startsWith("/")) { + url = url.substring(1); + } + + HttpClient c = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + return c.send( + HttpRequest.newBuilder(URI.create(jenkinsRule.getURL() + url)) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + /** + * performs a GET request using a basic authorization header + * + * @param user - The user id + * @param token - the password api token to user + * @param url - the url to request + * @return HttpResponse + */ + public static HttpResponse getPageWithGet(JenkinsRule jenkinsRule, String user, String token, String url) + throws IOException, InterruptedException { + // fix up the url, if needed + if (url.startsWith("/")) { + url = url.substring(1); + } + + HttpClient c = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + return c.send( + HttpRequest.newBuilder(URI.create(jenkinsRule.getURL() + url)) + .header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString((user + ":" + token).getBytes(StandardCharsets.UTF_8))) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + public static void expire(JenkinsRule.WebClient webClient) throws Exception { + webClient.executeOnServer(() -> { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + User user = User.get2(authentication); + if (user == null) { + return null; + } + OicCredentials credentials = user.getProperty(OicCredentials.class); + + // setting currentTimestamp == 1 guarantees this will be an expired cred + user.addProperty(new OicCredentials( + credentials.getAccessToken(), + credentials.getIdToken(), + credentials.getRefreshToken(), + 60L, + 1L, + 60L)); + return null; + }); + } +}