Skip to content

Commit e70636c

Browse files
authored
Merge pull request #386 from mikecirioli/support_traditional_token_access
Allow access using a Jenkins API token without an OIDC Session
2 parents d925bf7 + 791ef3c commit e70636c

File tree

5 files changed

+127
-1
lines changed

5 files changed

+127
-1
lines changed

src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
import java.time.format.DateTimeFormatter;
8484
import java.util.ArrayList;
8585
import java.util.Arrays;
86+
import java.util.Base64;
8687
import java.util.Collections;
8788
import java.util.HashSet;
8889
import java.util.List;
@@ -102,6 +103,7 @@
102103
import javax.servlet.http.HttpServletResponse;
103104
import javax.servlet.http.HttpSession;
104105
import jenkins.model.Jenkins;
106+
import jenkins.security.ApiTokenProperty;
105107
import jenkins.security.SecurityListener;
106108
import org.apache.commons.lang.StringUtils;
107109
import org.kohsuke.accmod.Restricted;
@@ -218,6 +220,10 @@ public static enum TokenAuthMethod {
218220
*/
219221
private boolean tokenExpirationCheckDisabled = false;
220222

223+
/** Flag to enable traditional Jenkins API token based access (no OicSession needed)
224+
*/
225+
private boolean allowTokenAccessWithoutOicSession = false;
226+
221227
/** Additional number of seconds to add to token expiration
222228
*/
223229
private Long allowedTokenExpirationClockSkewSeconds = 60L;
@@ -539,6 +545,10 @@ public boolean isTokenExpirationCheckDisabled() {
539545
return tokenExpirationCheckDisabled;
540546
}
541547

548+
public boolean isAllowTokenAccessWithoutOicSession() {
549+
return allowTokenAccessWithoutOicSession;
550+
}
551+
542552
public Long getAllowedTokenExpirationClockSkewSeconds() {
543553
return allowedTokenExpirationClockSkewSeconds;
544554
}
@@ -807,6 +817,11 @@ public void setTokenExpirationCheckDisabled(boolean tokenExpirationCheckDisabled
807817
this.tokenExpirationCheckDisabled = tokenExpirationCheckDisabled;
808818
}
809819

820+
@DataBoundSetter
821+
public void setAllowTokenAccessWithoutOicSession(boolean allowTokenAccessWithoutOicSession) {
822+
this.allowTokenAccessWithoutOicSession = allowTokenAccessWithoutOicSession;
823+
}
824+
810825
@DataBoundSetter
811826
public void setAllowedTokenExpirationClockSkewSeconds(Long allowedTokenExpirationClockSkewSeconds) {
812827
this.allowedTokenExpirationClockSkewSeconds = allowedTokenExpirationClockSkewSeconds;
@@ -1394,6 +1409,21 @@ public boolean handleTokenExpiration(HttpServletRequest httpRequest, HttpServlet
13941409

13951410
User user = User.get2(authentication);
13961411

1412+
if (isAllowTokenAccessWithoutOicSession()) {
1413+
// check if this is a valid api token based request
1414+
String authHeader = httpRequest.getHeader("Authorization");
1415+
if (authHeader != null && authHeader.startsWith("Basic ")) {
1416+
String token = new String(Base64.getDecoder().decode(authHeader.substring(6)), StandardCharsets.UTF_8)
1417+
.split(":")[1];
1418+
1419+
if (user.getProperty(ApiTokenProperty.class).matchesPassword(token)) {
1420+
// this was a valid jenkins token being used, exit this filter and let
1421+
// the rest of chain be processed
1422+
return true;
1423+
} // else do nothing and continue evaluating this request
1424+
}
1425+
}
1426+
13971427
if (user == null) {
13981428
return true;
13991429
}

src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.jelly

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@
113113
<f:entry title="${%AllowedTokenExpirationClockSkewSeconds}" field="allowedTokenExpirationClockSkewSeconds">
114114
<f:textbox/>
115115
</f:entry>
116+
<f:entry title="${%AllowTokenAccessWithoutOicSession}" field="allowTokenAccessWithoutOicSession">
117+
<f:checkbox/>
118+
</f:entry>
116119
</f:advanced>
117120

118121
<f:block>

src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ UsernameFieldName=User name field name
3838
WellknownConfigurationEndpoint=Well-known configuration endpoint
3939
UseRefreshTokens=Enable Token Refresh using Refresh Tokens
4040
DisableTokenExpirationCheck=Disable Token Expiration Check
41-
AllowedTokenExpirationClockSkewSeconds=Token Expiry Expiration Clock Skew
41+
AllowedTokenExpirationClockSkewSeconds=Token Expiry Expiration Clock Skew
42+
AllowTokenAccessWithoutOicSession=Allow access using a Jenkins API token without an OIDC Session
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div>
2+
Enabling this functionality allows Jenkins API token based access even if the associated user has
3+
completly logged out from Jenkins and the OIC Provider.
4+
5+
The default behavior is to require any Jenkins API token based access to have an valid OIC session user
6+
session associated with it. This means that the user associated with the Jenkins API token <ul>must</ul>
7+
be logged in via the UI in order to use an API token for Jenkins CLI access.
8+
</div>

src/test/java/org/jenkinsci/plugins/oic/PluginTest.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.net.http.HttpClient;
2222
import java.net.http.HttpRequest;
2323
import java.net.http.HttpResponse;
24+
import java.nio.charset.StandardCharsets;
2425
import java.security.KeyPair;
2526
import java.security.KeyPairGenerator;
2627
import java.security.MessageDigest;
@@ -38,6 +39,7 @@
3839
import java.util.regex.Pattern;
3940
import javax.servlet.http.HttpSession;
4041
import jenkins.model.Jenkins;
42+
import jenkins.security.ApiTokenProperty;
4143
import jenkins.security.LastGrantedAuthoritiesProperty;
4244
import org.hamcrest.MatcherAssert;
4345
import org.htmlunit.html.HtmlPage;
@@ -67,6 +69,7 @@
6769
import static com.github.tomakehurst.wiremock.client.WireMock.verify;
6870
import static com.google.gson.JsonParser.parseString;
6971
import static org.hamcrest.MatcherAssert.assertThat;
72+
import static org.hamcrest.Matchers.containsString;
7073
import static org.hamcrest.Matchers.empty;
7174
import static org.hamcrest.Matchers.is;
7275
import static org.jenkinsci.plugins.oic.TestRealm.AUTO_CONFIG_FIELD;
@@ -367,6 +370,37 @@ private HttpResponse<String> getPageWithGet(String url) throws IOException, Inte
367370
HttpResponse.BodyHandlers.ofString());
368371
}
369372

373+
/**
374+
* performs a GET request using a basic authorization header
375+
* @param user - The user id
376+
* @param token - the password api token to user
377+
* @param url - the url to request
378+
* @return HttpResponse
379+
* @throws IOException
380+
* @throws InterruptedException
381+
*/
382+
private HttpResponse<String> getPageWithGet(String user, String token, String url)
383+
throws IOException, InterruptedException {
384+
// fix up the url, if needed
385+
if (url.startsWith("/")) {
386+
url = url.substring(1);
387+
}
388+
389+
HttpClient c = HttpClient.newBuilder()
390+
.followRedirects(HttpClient.Redirect.ALWAYS)
391+
.build();
392+
return c.send(
393+
HttpRequest.newBuilder(URI.create(jenkinsRule.getURL() + url))
394+
.header(
395+
"Authorization",
396+
"Basic "
397+
+ Base64.getEncoder()
398+
.encodeToString((user + ":" + token).getBytes(StandardCharsets.UTF_8)))
399+
.GET()
400+
.build(),
401+
HttpResponse.BodyHandlers.ofString());
402+
}
403+
370404
@Test
371405
public void testRefreshTokenAndTokenExpiration_withoutRefreshToken() throws Exception {
372406
mockAuthorizationRedirectsToFinishLogin();
@@ -948,6 +982,56 @@ public void loginWithCheckTokenFailure() throws Exception {
948982
assertAnonymous();
949983
}
950984

985+
@Test
986+
public void testAccessUsingJenkinsApiTokens() throws Exception {
987+
mockAuthorizationRedirectsToFinishLogin();
988+
configureWellKnown(null, null, "authorization_code");
989+
jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, EMAIL_FIELD, GROUPS_FIELD, AUTO_CONFIG_FIELD));
990+
// explicitly ensure allowTokenAccessWithoutOicSession is disabled
991+
TestRealm testRealm = (TestRealm) jenkins.getSecurityRealm();
992+
testRealm.setAllowTokenAccessWithoutOicSession(false);
993+
994+
// login and assert normal auth is working
995+
mockTokenReturnsIdTokenWithGroup(PluginTest::withoutRefreshToken);
996+
mockUserInfoWithTestGroups();
997+
browseLoginPage();
998+
assertTestUser();
999+
1000+
// create a jenkins api token for the test user
1001+
String token = User.getById(TEST_USER_USERNAME, false)
1002+
.getProperty(ApiTokenProperty.class)
1003+
.generateNewToken("foo")
1004+
.plainValue;
1005+
1006+
// validate that the token can be used
1007+
HttpResponse<String> rsp = getPageWithGet(TEST_USER_USERNAME, token, "/whoAmI/api/xml");
1008+
MatcherAssert.assertThat("response should have been 200\n" + rsp.body(), rsp.statusCode(), is(200));
1009+
1010+
MatcherAssert.assertThat(
1011+
"response should have been 200\n" + rsp.body(),
1012+
rsp.body(),
1013+
containsString("<authenticated>true</authenticated>"));
1014+
1015+
// expired oic session tokens, do not refreshed
1016+
expire();
1017+
1018+
// the default behavior expects there to be a valid oic session, so token based
1019+
// access should now fail (unauthorized)
1020+
rsp = getPageWithGet(TEST_USER_USERNAME, token, "/whoAmI/api/xml");
1021+
MatcherAssert.assertThat("response should have been 401\n" + rsp.body(), rsp.statusCode(), is(401));
1022+
1023+
// enable "traditional api token access"
1024+
testRealm.setAllowTokenAccessWithoutOicSession(true);
1025+
1026+
// verify that jenkins api token is now working again
1027+
rsp = getPageWithGet(TEST_USER_USERNAME, token, "/whoAmI/api/xml");
1028+
MatcherAssert.assertThat("response should have been 200\n" + rsp.body(), rsp.statusCode(), is(200));
1029+
MatcherAssert.assertThat(
1030+
"response should have been 200\n" + rsp.body(),
1031+
rsp.body(),
1032+
containsString("<authenticated>true</authenticated>"));
1033+
}
1034+
9511035
private static @NonNull Consumer<OicSecurityRealm> belongsToGroup(String groupName) {
9521036
return sc -> {
9531037
sc.setTokenFieldToCheckKey("contains(groups, '" + groupName + "')");

0 commit comments

Comments
 (0)