diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java b/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java index 3cdf9cb4..8d922da2 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java @@ -83,6 +83,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -102,6 +103,7 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import jenkins.model.Jenkins; +import jenkins.security.ApiTokenProperty; import jenkins.security.SecurityListener; import org.apache.commons.lang.StringUtils; import org.kohsuke.accmod.Restricted; @@ -218,6 +220,10 @@ public static enum TokenAuthMethod { */ private boolean tokenExpirationCheckDisabled = false; + /** Flag to enable traditional Jenkins API token based access (no OicSession needed) + */ + private boolean allowTokenAccessWithoutOicSession = false; + /** Additional number of seconds to add to token expiration */ private Long allowedTokenExpirationClockSkewSeconds = 60L; @@ -539,6 +545,10 @@ public boolean isTokenExpirationCheckDisabled() { return tokenExpirationCheckDisabled; } + public boolean isAllowTokenAccessWithoutOicSession() { + return allowTokenAccessWithoutOicSession; + } + public Long getAllowedTokenExpirationClockSkewSeconds() { return allowedTokenExpirationClockSkewSeconds; } @@ -807,6 +817,11 @@ public void setTokenExpirationCheckDisabled(boolean tokenExpirationCheckDisabled this.tokenExpirationCheckDisabled = tokenExpirationCheckDisabled; } + @DataBoundSetter + public void setAllowTokenAccessWithoutOicSession(boolean allowTokenAccessWithoutOicSession) { + this.allowTokenAccessWithoutOicSession = allowTokenAccessWithoutOicSession; + } + @DataBoundSetter public void setAllowedTokenExpirationClockSkewSeconds(Long allowedTokenExpirationClockSkewSeconds) { this.allowedTokenExpirationClockSkewSeconds = allowedTokenExpirationClockSkewSeconds; @@ -1394,6 +1409,21 @@ public boolean handleTokenExpiration(HttpServletRequest httpRequest, HttpServlet User user = User.get2(authentication); + 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]; + + if (user.getProperty(ApiTokenProperty.class).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 + } + } + if (user == null) { return true; } diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.jelly b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.jelly index 0f807044..6f758789 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.jelly @@ -113,6 +113,9 @@ + + + diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.properties b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.properties index cbf370fd..daf01cf1 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.properties +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.properties @@ -38,4 +38,5 @@ UsernameFieldName=User name field name WellknownConfigurationEndpoint=Well-known configuration endpoint UseRefreshTokens=Enable Token Refresh using Refresh Tokens DisableTokenExpirationCheck=Disable Token Expiration Check -AllowedTokenExpirationClockSkewSeconds=Token Expiry Expiration Clock Skew \ No newline at end of file +AllowedTokenExpirationClockSkewSeconds=Token Expiry Expiration Clock Skew +AllowTokenAccessWithoutOicSession=Allow access using a Jenkins API token without an OIDC Session \ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-allowTokenAccessWithoutOicSession.html b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-allowTokenAccessWithoutOicSession.html new file mode 100644 index 00000000..86f5fcbb --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-allowTokenAccessWithoutOicSession.html @@ -0,0 +1,8 @@ +
+ Enabling this functionality allows Jenkins API token based access even if the associated user has + completly logged out from Jenkins and the OIC Provider. + + The default behavior is to require any Jenkins API token based access to have an valid OIC session user + session associated with it. This means that the user associated with the Jenkins API token + be logged in via the UI in order to use an API token for Jenkins CLI access. +
\ No newline at end of file diff --git a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java index 85af5782..244c6526 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java @@ -21,6 +21,7 @@ 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; @@ -38,6 +39,7 @@ import java.util.regex.Pattern; import javax.servlet.http.HttpSession; import jenkins.model.Jenkins; +import jenkins.security.ApiTokenProperty; import jenkins.security.LastGrantedAuthoritiesProperty; import org.hamcrest.MatcherAssert; import org.htmlunit.html.HtmlPage; @@ -67,6 +69,7 @@ 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.AUTO_CONFIG_FIELD; @@ -367,6 +370,37 @@ private HttpResponse getPageWithGet(String url) throws IOException, Inte 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(); @@ -948,6 +982,56 @@ public void loginWithCheckTokenFailure() throws Exception { assertAnonymous(); } + @Test + public void testAccessUsingJenkinsApiTokens() throws Exception { + mockAuthorizationRedirectsToFinishLogin(); + configureWellKnown(null, null, "authorization_code"); + jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, AUTO_CONFIG_FIELD)); + // 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 401\n" + rsp.body(), rsp.statusCode(), is(401)); + + // 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 + "')");