Skip to content

Commit

Permalink
Merge pull request #386 from mikecirioli/support_traditional_token_ac…
Browse files Browse the repository at this point in the history
…cess

Allow access using a Jenkins API token without an OIDC Session
  • Loading branch information
mikecirioli committed Sep 10, 2024
2 parents d925bf7 + 791ef3c commit e70636c
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 1 deletion.
30 changes: 30 additions & 0 deletions src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -539,6 +545,10 @@ public boolean isTokenExpirationCheckDisabled() {
return tokenExpirationCheckDisabled;
}

public boolean isAllowTokenAccessWithoutOicSession() {
return allowTokenAccessWithoutOicSession;
}

public Long getAllowedTokenExpirationClockSkewSeconds() {
return allowedTokenExpirationClockSkewSeconds;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ")) {

Check warning on line 1415 in src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 1415 is only partially covered, 2 branches are missing
String token = new String(Base64.getDecoder().decode(authHeader.substring(6)), StandardCharsets.UTF_8)
.split(":")[1];

if (user.getProperty(ApiTokenProperty.class).matchesPassword(token)) {

Check warning on line 1419 in src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 1419 is only partially covered, one branch is missing
// 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@
<f:entry title="${%AllowedTokenExpirationClockSkewSeconds}" field="allowedTokenExpirationClockSkewSeconds">
<f:textbox/>
</f:entry>
<f:entry title="${%AllowTokenAccessWithoutOicSession}" field="allowTokenAccessWithoutOicSession">
<f:checkbox/>
</f:entry>
</f:advanced>

<f:block>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
AllowedTokenExpirationClockSkewSeconds=Token Expiry Expiration Clock Skew
AllowTokenAccessWithoutOicSession=Allow access using a Jenkins API token without an OIDC Session
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div>
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 <ul>must</ul>
be logged in via the UI in order to use an API token for Jenkins CLI access.
</div>
84 changes: 84 additions & 0 deletions src/test/java/org/jenkinsci/plugins/oic/PluginTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -367,6 +370,37 @@ private HttpResponse<String> 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<String> 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();
Expand Down Expand Up @@ -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<String> 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("<authenticated>true</authenticated>"));

// 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("<authenticated>true</authenticated>"));
}

private static @NonNull Consumer<OicSecurityRealm> belongsToGroup(String groupName) {
return sc -> {
sc.setTokenFieldToCheckKey("contains(groups, '" + groupName + "')");
Expand Down

0 comments on commit e70636c

Please sign in to comment.