diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..26a6a674 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Text by default +* text=auto + +# Images +*.jpg binary +*.png binary diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 2eb0b797..93da9510 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -10,15 +10,94 @@ There are specifics instructions for well known providers: * [Google Provider](GOOGLE.md) * [Gitlab Provider](GITLAB.md) +This page contains the reference of plugin's configuration. ## Provider configuration -The OpenID Conenct spec describes a well known configuration location +The OpenID Connect spec describes a well known configuration location which will also help discovering your settings () From 1.5 and onward the well known configuration location may be used to -populate the configuration simplifying the configuration greatly. +populate the configuration simplifying the configuration greatly. +The switch between modes is controled by the `automanualconfigure` field + +| field | format | description | +| ----- | ------ | ----------- | +| automanualconfigure | enum | Crontols endpoint configuration mode
- `auto`: activate automatic configuration
- `manual`: activate manual configuration | +| clientId | string | Id of the openid client obtained from the provider | +| clientSecret | secret | Secret associated to the client | + +### Automatic configuration + +In automatic mode, the [well-known](https://datatracker.ietf.org/doc/html/rfc5785) +configuration endpoint is regularly fetched and parse to fill the fields +required in manual configuration. By default, all scopes are requested +but this can be overriden by the `overrideScopes` config parameter. + +| field | format | description | +| ----- | ------ | ----------- | +| wellKnownOpenIDConfigurationUrl | url | Providers' well-known configuration endpoint | +| overrideScopes | string | Space separated list of scopes to request (default: request all) | + +When configuring from the interface, the automatic mode will fill in the +fields expected in manual mode. This can be useful for prefilling the +fields but adapting the configuration of the endpoints. + +### Manual configuration + +The manual configuration mut provide the authorization and token endpoints. +The scopes can be configured but default to `openid profile`. +If the JWKS endpoint is configured, JWS' signatures will be verified +(unless disabled). + +| field | format | description | +| ----- | ------ | ----------- | +| automanualconfigure | enum | Always `manual` in manual mode | +| authorizationServerUrl | url | URL the user is redirected to at login | +| tokenServerUrl | url | URL used by jenkins to request the tokens | +| endSessionEndpoint | url | URL to logout from provider (used if activated) | +| jwksServerUrl | url | URL of provider's jws certificates (unused if disabled) | +| scopes | string | Space separated list of scopes to request (default: request all) | +| tokenAuthMethod | enum | method used for authenticating when requesting token(s)
- `client_secret_basic`: for client id/secret as basic authentication user/pass
- `client_secret_post`: for client id/secret sent in post request +| userInfoServerUrl | url | URL to get user's details | + +### Advanced configuration + +Providers have some variation in their implementation of OpenID Connect +or some oddities they required. + +| field | format | description | +| ----- | ------ | ----------- | +| logoutFromOpenidProvider | boolean | Enable the logout from provider when user logout from Jenkisn. | +| sendScopesInTokenRequest | boolean | Some providers expects scopes to be sent in token request | +| rootURLFromRequest | boolean | When computing Jenkins redirect, the root url is either deduced from configured root url or request | + +### Security configuration + +Most security feature are activated by default if possible. + +| field | format | description | +| ----- | ------ | ----------- | +| disableSslVerification | boolean | disable SSL verification (in case of self signed certificates by example) | +| nonceDisabled | boolean | Disable nonce verification | +| pkceEnable | boolean | Enable PKCE challenge | +| disableTokenVerification | boolean | Disable IdToken and UserInfo verification (not recommended) | +| tokenFieldToCheckKey | jmespath | field(s) to check to authorize user | +| tokenFieldToCheckValue | string | tokenFieldToCheckValue expected value | + +## User information + +Content of idtoken or user info to use for identifying the user. +They are called claims in OpenID Connect terminology. + +| field | format | description | +| ----- | ------ | ----------- | +| userNameField | jmes path | claim to use as user login (default: `sub`) | +| fullNameFieldName | jmes path | claim to use as name of user | +| emailFieldName | jmes path | claim to use for populating user email | +| groupsFieldName |jmes path | groups the user belongs to | + ## JCasC configuration reference @@ -28,17 +107,21 @@ JCasC configuration can be defined with the following fields: jenkins: securityRealm: oic: - # Endpoints automanualconfigure: + # Automatic config of endpoint wellKnownOpenIDConfigurationUrl: + overrideScopes: + # Manual config of endpoint tokenServerUrl: authorizationServerUrl: + endSessionEndpoint: + jwksServerUrl: + scopes: # Credentials clientId: clientSecret: tokenAuthMethod: # claims - scopes: userNameField: groupsFieldName: fullNameFieldName: @@ -51,9 +134,10 @@ jenkins: disableSslVerification: nonceDisabled: pkceEnabled: + disableTokenVerification: tokenFieldToCheckKey: - tokenFieldToCheckValue: string - # escape hatch + tokenFieldToCheckValue: + # escape hatch escapeHatchEnabled: escapeHatchUsername: escapeHatchUsername escapeHatchSecret: diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifier.java b/src/main/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifier.java new file mode 100644 index 00000000..d77ab087 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifier.java @@ -0,0 +1,90 @@ +/* + * The MIT License + * + * Copyright (c) 2024 JenkinsCI oic-auth-plugin developers + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jenkinsci.plugins.oic; + +import com.google.api.client.auth.openidconnect.IdToken; +import com.google.api.client.auth.openidconnect.IdTokenVerifier; +import com.google.api.client.json.webtoken.JsonWebSignature; +import hudson.Util; +import java.io.IOException; + +/** + * Extend IdTokenVerifier to verify UserInfo webtoken + */ +public class OicJsonWebTokenVerifier extends IdTokenVerifier { + + /** Bypass Signature verification if no JWKS url configured */ + private final boolean hasNoJwksServerUrl; + + /** Payload indicating userInfo */ + private static final IdToken.Payload NO_PAYLOAD = new IdToken.Payload(); + + /** + * Default verifier + */ + public OicJsonWebTokenVerifier() { + super(); + hasNoJwksServerUrl = true; + } + + /** + * Verifier with custom builder + */ + public OicJsonWebTokenVerifier(String jwksServerUrl, IdTokenVerifier.Builder builder) { + super(builder.setCertificatesLocation(jwksServerUrl)); + hasNoJwksServerUrl = (Util.fixEmptyAndTrim(jwksServerUrl) == null); + } + + /** Verify real idtoken */ + public boolean verifyIdToken(IdToken idToken) throws IOException { + if (hasNoJwksServerUrl) { + /* avoid Google's certificate fallback mechanism */ + return super.verifyPayload(idToken); + } + return verifyOrThrow(idToken); + } + + /** Verify userinfo jwt token */ + public boolean verifyUserInfo(JsonWebSignature userinfo) throws IOException { + if (hasNoJwksServerUrl) { + /* avoid Google's certificate fallback mechanism */ + return true; + } + IdToken idToken = new IdToken( + userinfo.getHeader(), + NO_PAYLOAD, /* bypass verification of payload */ + userinfo.getSignatureBytes(), + userinfo.getSignedContentBytes()); + return verifyOrThrow(idToken); + } + + /** hack: verify payload only if idtoken is not userinfo */ + @Override + protected boolean verifyPayload(IdToken idToken) { + if (idToken.getPayload() == NO_PAYLOAD) { + return true; + } + return super.verifyPayload(idToken); + } +} diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java b/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java index 28e36acb..b394edb0 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java @@ -28,6 +28,7 @@ import com.google.api.client.auth.oauth2.BearerToken; import com.google.api.client.auth.oauth2.ClientParametersAuthentication; import com.google.api.client.auth.oauth2.Credential.AccessMethod; +import com.google.api.client.auth.openidconnect.HttpTransportFactory; import com.google.api.client.auth.openidconnect.IdToken; import com.google.api.client.http.BasicAuthentication; import com.google.api.client.http.GenericUrl; @@ -139,6 +140,7 @@ public static enum TokenAuthMethod { private final Secret clientSecret; private String wellKnownOpenIDConfigurationUrl = null; private String tokenServerUrl = null; + private String jwksServerUrl = null; private TokenAuthMethod tokenAuthMethod; private String authorizationServerUrl = null; private String userInfoServerUrl = null; @@ -188,6 +190,10 @@ public static enum TokenAuthMethod { */ private boolean pkceEnabled = false; + /** Flag to disable JWT signature verification + */ + private boolean disableTokenVerification = false; + /** Flag to disable nonce security */ private boolean nonceDisabled = false; @@ -201,7 +207,11 @@ public static enum TokenAuthMethod { * but it's still needed for backwards compatibility */ private transient String endSessionUrl; - private transient HttpTransport httpTransport; + /** Verification of IdToken and UserInfo (in jwt case) + */ + private transient OicJsonWebTokenVerifier jwtVerifier; + + private transient HttpTransport httpTransport = null; /** Random generator needed for robust random wait */ @@ -221,6 +231,7 @@ public OicSecurityRealm( String clientSecret, String wellKnownOpenIDConfigurationUrl, String tokenServerUrl, + String jwksServerUrl, String tokenAuthMethod, String authorizationServerUrl, String userInfoServerUrl, @@ -254,6 +265,7 @@ public OicSecurityRealm( this.tokenAuthMethod = TokenAuthMethod.valueOf(StringUtils.defaultIfBlank(tokenAuthMethod, "client_secret_post")); this.userInfoServerUrl = userInfoServerUrl; + this.jwksServerUrl = jwksServerUrl; this.setScopes(scopes); this.endSessionEndpoint = endSessionEndpoint; @@ -288,6 +300,7 @@ public OicSecurityRealm( String clientSecret, String authorizationServerUrl, String tokenServerUrl, + String jwksServerUrl, String tokenAuthMethod, String userInfoServerUrl, String endSessionEndpoint, @@ -307,6 +320,7 @@ public OicSecurityRealm( // previous values of OpenIDConnect configuration this.authorizationServerUrl = authorizationServerUrl; this.tokenServerUrl = tokenServerUrl; + this.jwksServerUrl = jwksServerUrl; this.tokenAuthMethod = TokenAuthMethod.valueOf(StringUtils.defaultIfBlank(tokenAuthMethod, "client_secret_post")); this.userInfoServerUrl = userInfoServerUrl; @@ -379,6 +393,10 @@ public String getTokenServerUrl() { return tokenServerUrl; } + public String getJwksServerUrl() { + return jwksServerUrl; + } + public TokenAuthMethod getTokenAuthMethod() { return tokenAuthMethod; } @@ -475,6 +493,10 @@ public boolean isPkceEnabled() { return pkceEnabled; } + public boolean isDisableTokenVerification() { + return disableTokenVerification; + } + public boolean isNonceDisabled() { return nonceDisabled; } @@ -511,6 +533,7 @@ private void loadWellKnownOpenIDConfigurationUrl() { this.authorizationServerUrl = Util.fixNull(config.getAuthorizationEndpoint(), this.authorizationServerUrl); this.tokenServerUrl = Util.fixNull(config.getTokenEndpoint(), this.tokenServerUrl); + this.jwksServerUrl = Util.fixNull(config.getJwksUri(), this.jwksServerUrl); this.tokenAuthMethod = Util.fixNull(config.getPreferredTokenAuthMethod(), this.tokenAuthMethod); this.userInfoServerUrl = Util.fixNull(config.getUserinfoEndpoint(), this.userInfoServerUrl); if (config.getScopesSupported() != null) { @@ -723,6 +746,11 @@ public void setPkceEnabled(boolean pkceEnabled) { this.pkceEnabled = pkceEnabled; } + @DataBoundSetter + public void setDisableTokenVerification(boolean disableTokenVerification) { + this. disableTokenVerification = disableTokenVerification; + } + @DataBoundSetter public void setNonceDisabled(boolean nonceDisabled) { this.nonceDisabled = nonceDisabled; @@ -881,6 +909,9 @@ public HttpResponse onSuccess(String authorizationCode, AuthorizationCodeFlow fl } catch(IllegalArgumentException e) { return HttpResponses.errorWithoutStack(403, Messages.OicSecurityRealm_IdTokenParseError()); } + if (!validateIdToken(idToken)) { + return HttpResponses.errorWithoutStack(401, "Unauthorized"); + } if (!isNonceDisabled() && !validateNonce(idToken)) { return HttpResponses.errorWithoutStack(401, "Unauthorized"); } @@ -894,6 +925,9 @@ public HttpResponse onSuccess(String authorizationCode, AuthorizationCodeFlow fl GenericJson userInfo = null; if (!Strings.isNullOrEmpty(userInfoServerUrl)) { userInfo = getUserInfo(flow, response.getAccessToken()); + if (userInfo == null) { + return HttpResponses.errorWithoutStack(401, "Unauthorized"); + } } String username = determineStringField(userNameFieldExpr, idToken, userInfo); @@ -916,6 +950,43 @@ public HttpResponse onSuccess(String authorizationCode, AuthorizationCodeFlow fl .commenceLogin(buildAuthorizationCodeFlow()); } + /** Create OicJsonWebTokenVerifier if needed */ + private OicJsonWebTokenVerifier getJwksVerifier() { + if (isDisableTokenVerification()) { + return null; + } + if (jwtVerifier == null) { + jwtVerifier = new OicJsonWebTokenVerifier( + jwksServerUrl, + new OicJsonWebTokenVerifier.Builder() + .setHttpTransportFactory(new HttpTransportFactory() { + @Override + public HttpTransport create() { + return httpTransport; + } + })); + } + return jwtVerifier; + } + + /** Validate UserInfo signature if available */ + private boolean validateUserInfo(JsonWebSignature userinfo) throws IOException { + OicJsonWebTokenVerifier verifier = getJwksVerifier(); + if (verifier == null) { + return true; + } + return verifier.verifyUserInfo(userinfo); + } + + /** Validate IdToken signature if available */ + private boolean validateIdToken(IdToken idtoken) throws IOException { + OicJsonWebTokenVerifier verifier = getJwksVerifier(); + if (verifier == null) { + return true; + } + return verifier.verifyIdToken(idtoken); + } + @SuppressFBWarnings( value = "DMI_RANDOM_USED_ONLY_ONCE", justification = "False positive in spotbug about DMI_RANDOM_USED_ONLY_ONCE") @@ -942,6 +1013,9 @@ public void initialize(HttpRequest request) throws IOException { if (response.getHeaders().getContentType().contains("application/jwt")) { String token = response.parseAsString(); JsonWebSignature jws = JsonWebSignature.parse(flow.getJsonFactory(), token); + if (!validateUserInfo(jws)) { + return null; + } return jws.getPayload(); } @@ -1285,6 +1359,20 @@ public FormValidation doCheckTokenServerUrl(@QueryParameter String tokenServerUr } } + @RequirePOST + public FormValidation doCheckJwksServerUrl(@QueryParameter String jwksServerUrl) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + if (Util.fixEmptyAndTrim(jwksServerUrl) == null) { + return FormValidation.ok(); + } + try { + new URL(jwksServerUrl); + return FormValidation.ok(); + } catch (MalformedURLException e) { + return FormValidation.error(e, Messages.OicSecurityRealm_NotAValidURL()); + } + } + @RequirePOST public FormValidation doCheckTokenAuthMethod(@QueryParameter String tokenAuthMethod) { Jenkins.get().checkPermission(Jenkins.ADMINISTER); 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 5d09d221..341529f0 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/config.jelly @@ -42,6 +42,9 @@ + + + @@ -95,6 +98,9 @@ + + + diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-disableSignatureVerification.html b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-disableSignatureVerification.html new file mode 100644 index 00000000..0260f4df --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-disableSignatureVerification.html @@ -0,0 +1,4 @@ +
+ Disable verification of signature of idtoken and (if applcable) of userinfo. +
+ diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-jwksServerUrl.html b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-jwksServerUrl.html new file mode 100644 index 00000000..5bced9d5 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-jwksServerUrl.html @@ -0,0 +1,4 @@ +
+ Recommended. jswon webtoken key signature url of the openid connect provider +
+ diff --git a/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java b/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java index 0865ff37..7804ba03 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java @@ -42,7 +42,7 @@ public void testConfig() { assertTrue(realm instanceof OicSecurityRealm); OicSecurityRealm oicSecurityRealm = (OicSecurityRealm) realm; - assertEquals("http://localhost", oicSecurityRealm.getAuthorizationServerUrl()); + assertEquals("http://localhost/authorize", oicSecurityRealm.getAuthorizationServerUrl()); assertEquals("clientId", oicSecurityRealm.getClientId()); assertEquals("clientSecret", Secret.toString(oicSecurityRealm.getClientSecret())); assertTrue(oicSecurityRealm.isDisableSslVerification()); @@ -57,10 +57,12 @@ public void testConfig() { assertEquals("groupsFieldName", oicSecurityRealm.getGroupsFieldName()); assertTrue(oicSecurityRealm.isLogoutFromOpenidProvider()); assertEquals("scopes", oicSecurityRealm.getScopes()); - assertEquals("http://localhost", oicSecurityRealm.getTokenServerUrl()); + assertEquals("http://localhost/token", oicSecurityRealm.getTokenServerUrl()); assertEquals(TokenAuthMethod.client_secret_post, oicSecurityRealm.getTokenAuthMethod()); assertEquals("userNameField", oicSecurityRealm.getUserNameField()); assertTrue(oicSecurityRealm.isRootURLFromRequest()); + assertEquals("http://localhost/jwks", oicSecurityRealm.getJwksServerUrl()); + assertFalse(oicSecurityRealm.isDisableTokenVerification()); } @Test @@ -68,8 +70,10 @@ public void testConfig() { public void testExport() throws Exception { ConfigurationContext context = new ConfigurationContext(ConfiguratorRegistry.get()); - CNode yourAttribute = - getJenkinsRoot(context).get("securityRealm").asMapping().get("oic"); + CNode yourAttribute = getJenkinsRoot(context) + .get("securityRealm") + .asMapping() + .get("oic"); String exported = toYamlString(yourAttribute); @@ -110,6 +114,8 @@ public void testMinimal() throws Exception { assertEquals("sub", oicSecurityRealm.getUserNameField()); assertTrue(oicSecurityRealm.isLogoutFromOpenidProvider()); assertFalse(oicSecurityRealm.isRootURLFromRequest()); + assertEquals(null, oicSecurityRealm.getJwksServerUrl()); + assertFalse(oicSecurityRealm.isDisableTokenVerification()); } @Rule(order = 0) @@ -118,7 +124,7 @@ public void testMinimal() throws Exception { "{\"authorization_endpoint\": \"http://localhost:%1$d/authorize\"," + "\"token_endpoint\":\"http://localhost:%1$d/token\"," + "\"userinfo_endpoint\":\"http://localhost:%1$d/user\"," - + "\"jwks_uri\":\"http://localhost:%1$d/authorize/jwks\"," + + "\"jwks_uri\":\"http://localhost:%1$d/jwks\"," + "\"scopes_supported\": null," + "\"end_session_endpoint\":\"http://localhost:%1$d/logout\"}"); @@ -135,6 +141,7 @@ public void testMinimalWellKnown() throws Exception { assertEquals(urlBase + "/well.known", oicSecurityRealm.getWellKnownOpenIDConfigurationUrl()); assertEquals(urlBase + "/authorize", oicSecurityRealm.getAuthorizationServerUrl()); assertEquals(urlBase + "/token", oicSecurityRealm.getTokenServerUrl()); + assertEquals(urlBase + "/jwks", oicSecurityRealm.getJwksServerUrl()); assertEquals("clientId", oicSecurityRealm.getClientId()); assertEquals("clientSecret", Secret.toString(oicSecurityRealm.getClientSecret())); assertFalse(oicSecurityRealm.isDisableSslVerification()); @@ -147,6 +154,7 @@ public void testMinimalWellKnown() throws Exception { assertEquals(TokenAuthMethod.client_secret_post, oicSecurityRealm.getTokenAuthMethod()); assertEquals("sub", oicSecurityRealm.getUserNameField()); assertTrue(oicSecurityRealm.isLogoutFromOpenidProvider()); + assertFalse(oicSecurityRealm.isDisableTokenVerification()); } /** Class to setup WireMockRule for well known with stub and setting port in env variable diff --git a/src/test/java/org/jenkinsci/plugins/oic/DescriptorImplTest.java b/src/test/java/org/jenkinsci/plugins/oic/DescriptorImplTest.java index b556f6e8..25e37a65 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/DescriptorImplTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/DescriptorImplTest.java @@ -129,6 +129,19 @@ public void doCheckAuthorizationServerUrl() throws IOException { assertEquals(FormValidation.ok(), descriptor.doCheckAuthorizationServerUrl("http://localhost")); } + @Test + public void doCheckJwksServerUrl() throws IOException { + configureWellKnown(); + TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD); + + OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor(); + + assertNotNull(descriptor); + assertEquals(FormValidation.ok(), descriptor.doCheckJwksServerUrl(null)); + assertEquals(FormValidation.ok(), descriptor.doCheckJwksServerUrl("")); + assertEquals(FormValidation.ok(), descriptor.doCheckJwksServerUrl("http://localhost/jwks")); + } + @Test public void doCheckUserNameField() throws IOException { configureWellKnown(); diff --git a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java index a4f82a74..a53cbcda 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java @@ -8,6 +8,7 @@ import com.google.api.client.json.webtoken.JsonWebToken; import com.google.api.client.util.ArrayMap; import com.google.api.client.util.Base64; +import com.google.api.client.util.Clock; import com.google.gson.JsonElement; import hudson.model.User; import hudson.tasks.Mailer; @@ -16,6 +17,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.interfaces.RSAPublicKey; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -732,6 +734,123 @@ public void testLoginUsingUserInfoWithJWT() throws Exception { user.getAuthorities().contains(TEST_USER_GROUPS[0])); } + @Test + public void testLoginWithJWTSignature() throws Exception { + wireMockRule.resetAll(); + + KeyPair keyPair = createKeyPair(); + + wireMockRule.stubFor(get(urlPathEqualTo("/jwks")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"keys\":[{"+encodePublicKey(keyPair)+ + ",\"use\":\"sig\",\"kid\":\"jwks_key_id\""+ + "}]}"))); + wireMockRule.stubFor(get(urlPathEqualTo("/authorization")) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Content-Type", "text/html; charset=utf-8") + .withHeader( + "Location", jenkins.getRootUrl() + "securityRealm/finishLogin?state=state&code=code") + .withBody(""))); + wireMockRule.stubFor(post(urlPathEqualTo("/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{" + "\"id_token\": \"" + + createIdToken(keyPair.getPrivate(), Collections.emptyMap()) + "\"," + + "\"access_token\":\"AcCeSs_ToKeN\"," + + "\"token_type\":\"example\"," + + "\"expires_in\":3600," + + "\"refresh_token\":\"ReFrEsH_ToKeN\"," + + "\"example_parameter\":\"example_value\"" + + "}"))); + wireMockRule.stubFor(get(urlPathEqualTo("/userinfo")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/jwt") + .withBody(createUserInfoJWT( + keyPair.getPrivate(), + "{\n" + " \"sub\": \"" + + TEST_USER_USERNAME + "\",\n" + " \"" + + FULL_NAME_FIELD + "\": \"" + TEST_USER_FULL_NAME + "\",\n" + " \"" + + EMAIL_FIELD + "\": \"" + TEST_USER_EMAIL_ADDRESS + "\",\n" + " \"" + + GROUPS_FIELD + "\": \"" + TEST_USER_GROUPS[0] + "\"\n" + " }")))); + + jenkins.setSecurityRealm( + new TestRealm.Builder(wireMockRule) + .WithUserInfoServerUrl("http://localhost:" + wireMockRule.port() + "/userinfo") + .WithJwksServerUrl("http://localhost:" + wireMockRule.port() + "/jwks") + .build()); + + assertEquals("Shouldn't be authenticated", + getAuthentication().getPrincipal(), Jenkins.ANONYMOUS.getPrincipal()); + + webClient.goTo(jenkins.getSecurityRealm().getLoginUrl()); + + Authentication authentication = getAuthentication(); + assertEquals("Should be logged-in as " + TEST_USER_USERNAME, authentication.getPrincipal(), TEST_USER_USERNAME); + } + + public void testLoginWithWrongJWTSignature() throws Exception { + wireMockRule.resetAll(); + + KeyPair keyPair = createKeyPair(); + + wireMockRule.stubFor(get(urlPathEqualTo("/jwks")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"keys\":[{"+encodePublicKey(keyPair)+ + ",\"use\":\"sig\",\"kid\":\"wrong_key_id\""+ + "}]}"))); + wireMockRule.stubFor(get(urlPathEqualTo("/authorization")) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Content-Type", "text/html; charset=utf-8") + .withHeader( + "Location", jenkins.getRootUrl() + "securityRealm/finishLogin?state=state&code=code") + .withBody(""))); + wireMockRule.stubFor(post(urlPathEqualTo("/token")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{" + "\"id_token\": \"" + + createIdToken(keyPair.getPrivate(), Collections.emptyMap()) + "\"," + + "\"access_token\":\"AcCeSs_ToKeN\"," + + "\"token_type\":\"example\"," + + "\"expires_in\":3600," + + "\"refresh_token\":\"ReFrEsH_ToKeN\"," + + "\"example_parameter\":\"example_value\"" + + "}"))); + wireMockRule.stubFor(get(urlPathEqualTo("/userinfo")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/jwt") + .withBody(createUserInfoJWT( + keyPair.getPrivate(), + "{\n" + " \"sub\": \"" + + TEST_USER_USERNAME + "\",\n" + " \"" + + FULL_NAME_FIELD + "\": \"" + TEST_USER_FULL_NAME + "\",\n" + " \"" + + EMAIL_FIELD + "\": \"" + TEST_USER_EMAIL_ADDRESS + "\",\n" + " \"" + + GROUPS_FIELD + "\": \"" + TEST_USER_GROUPS[0] + "\"\n" + " }")))); + + TestRealm testRealm = new TestRealm.Builder(wireMockRule) + .WithUserInfoServerUrl("http://localhost:" + wireMockRule.port() + "/userinfo") + .WithJwksServerUrl("http://localhost:" + wireMockRule.port() + "/jwks") + .build(); + jenkins.setSecurityRealm(testRealm); + + assertEquals("Shouldn't be authenticated", + getAuthentication().getPrincipal(), Jenkins.ANONYMOUS.getPrincipal()); + + webClient.goTo(jenkins.getSecurityRealm().getLoginUrl()); + + assertEquals("Should have refused authentication", + getAuthentication().getPrincipal(), Jenkins.ANONYMOUS.getPrincipal()); + testRealm.setDisableTokenVerification(true); + + webClient.goTo(jenkins.getSecurityRealm().getLoginUrl()); + + Authentication authentication = getAuthentication(); + assertEquals("Should be logged-in as " + TEST_USER_USERNAME, authentication.getPrincipal(), TEST_USER_USERNAME); + } + @Test public void testShouldLogUserWithoutGroupsWhenUserGroupIsMissing() throws Exception { wireMockRule.resetAll(); @@ -1160,25 +1279,25 @@ public void testOicUserPropertyDescriptor() throws Exception { } private void configureWellKnown(String endSessionUrl, String scopesSupported) { - String authUrl = "http://localhost:" + wireMockRule.port() + "/authorization"; - String tokenUrl = "http://localhost:" + wireMockRule.port() + "/token"; - String userInfoUrl = "http://localhost:" + wireMockRule.port() + "/userinfo"; + String authUrl = "\"http://localhost:" + wireMockRule.port() + "/authorization\""; + String tokenUrl = "\"http://localhost:" + wireMockRule.port() + "/token\""; + String userInfoUrl = "\"http://localhost:" + wireMockRule.port() + "/userinfo\""; String jwksUrl = "null"; - String endSessionUrlStr = endSessionUrl == null ? "null" : endSessionUrl; + String endSessionUrlStr = endSessionUrl == null ? "null" : ('"' + endSessionUrl + '"'); wireMockRule.stubFor(get(urlPathEqualTo("/well.known")) .willReturn(aResponse() .withHeader("Content-Type", "text/html; charset=utf-8") .withBody(String.format( - "{\"authorization_endpoint\": \"%s\", \"token_endpoint\":\"%s\", " - + "\"userinfo_endpoint\":\"%s\",\"jwks_uri\":\"%s\", \"scopes_supported\": " + "{\"authorization_endpoint\": %s, \"token_endpoint\":%s, " + + "\"userinfo_endpoint\":%s,\"jwks_uri\":%s, \"scopes_supported\": " + scopesSupported + ", " - + "\"end_session_endpoint\":\"%s\"}", + + "\"end_session_endpoint\":%s}", authUrl, tokenUrl, userInfoUrl, jwksUrl, - endSessionUrl)))); + endSessionUrlStr)))); } @Test @@ -1269,8 +1388,13 @@ private KeyPair createKeyPair() throws NoSuchAlgorithmException { } private String createIdToken(PrivateKey privateKey, Map keyValues) throws Exception { - JsonWebSignature.Header header = new JsonWebSignature.Header().setAlgorithm("RS256"); + JsonWebSignature.Header header = new JsonWebSignature.Header() + .setAlgorithm("RS256") + .setKeyId("jwks_key_id"); + long now = (long)(Clock.SYSTEM.currentTimeMillis()/1000); IdToken.Payload payload = new IdToken.Payload() + .setExpirationTimeSeconds(now + 60L) + .setIssuedAtTimeSeconds(now) .setIssuer("issuer") .setSubject(TEST_USER_USERNAME) .setAudience(Collections.singletonList("clientId")) @@ -1284,7 +1408,9 @@ private String createIdToken(PrivateKey privateKey, Map keyValue private String createUserInfoJWT(PrivateKey privateKey, String userInfo) throws Exception { - JsonWebSignature.Header header = new JsonWebSignature.Header().setAlgorithm("RS256"); + JsonWebSignature.Header header = new JsonWebSignature.Header() + .setAlgorithm("RS256") + .setKeyId("jwks_key_id"); JsonWebToken.Payload payload = new JsonWebToken.Payload(); for (Map.Entry keyValue : @@ -1363,6 +1489,16 @@ public void testLoginWithUnreadableIdTokenShouldBeRefused() throws Exception { webClient.assertFails(jenkins.getSecurityRealm().getLoginUrl(), 403); } + /** Generate JWKS entry with public key of keyPair */ + String encodePublicKey(KeyPair keyPair) { + final RSAPublicKey rsaPKey = (RSAPublicKey)(keyPair.getPublic()); + return "\"n\":\"" + + Base64.encodeBase64String(rsaPKey.getModulus().toByteArray()) + + "\",\"e\":\"" + + Base64.encodeBase64String(rsaPKey.getPublicExponent().toByteArray()) + + "\",\"alg\":\"RS256\",\"kty\":\"RSA\""; + } + /** * Gets the authentication object from the web client. * diff --git a/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java b/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java index 974cea63..351dd1d7 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java +++ b/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java @@ -23,6 +23,7 @@ public static class Builder { public String clientSecret = "secret"; public String wellKnownOpenIDConfigurationUrl; public String tokenServerUrl; + public String jwksServerUrl = null; public String tokenAuthMethod = "client_secret_post"; public String authorizationServerUrl; public String userInfoServerUrl = null; @@ -64,6 +65,11 @@ public Builder WithUserInfoServerUrl(String userInfoServerUrl) { return this; } + public Builder WithJwksServerUrl(String jwksServerUrl) { + this.jwksServerUrl = jwksServerUrl; + return this; + } + public Builder WithEmailFieldName(String emailFieldName) { this.emailFieldName = emailFieldName; return this; @@ -123,6 +129,7 @@ public TestRealm(Builder builder) throws IOException { builder.clientSecret, builder.wellKnownOpenIDConfigurationUrl, builder.tokenServerUrl, + builder.jwksServerUrl, builder.tokenAuthMethod, builder.authorizationServerUrl, builder.userInfoServerUrl, diff --git a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml index 316cdf72..66faa45a 100644 --- a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml +++ b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml @@ -1,7 +1,7 @@ jenkins: securityRealm: oic: - authorizationServerUrl: http://localhost + authorizationServerUrl: http://localhost/authorize clientId: clientId clientSecret: clientSecret disableSslVerification: true @@ -12,10 +12,11 @@ jenkins: escapeHatchUsername: escapeHatchUsername fullNameFieldName: fullNameFieldName groupsFieldName: groupsFieldName + jwksServerUrl: http://localhost/jwks logoutFromOpenidProvider: true scopes: scopes tokenAuthMethod: client_secret_post - tokenServerUrl: http://localhost + tokenServerUrl: http://localhost/token userNameField: userNameField rootURLFromRequest: true sendScopesInTokenRequest: true diff --git a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml index b435ab3d..ff89d6d1 100644 --- a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml +++ b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml @@ -1,4 +1,4 @@ -authorizationServerUrl: "http://localhost" +authorizationServerUrl: "http://localhost/authorize" clientId: "clientId" disableSslVerification: true emailFieldName: "emailFieldName" @@ -7,11 +7,12 @@ escapeHatchGroup: "escapeHatchGroup" escapeHatchUsername: "escapeHatchUsername" fullNameFieldName: "fullNameFieldName" groupsFieldName: "groupsFieldName" +jwksServerUrl: "http://localhost/jwks" nonceDisabled: true pkceEnabled: true rootURLFromRequest: true scopes: "scopes" sendScopesInTokenRequest: true tokenAuthMethod: "client_secret_post" -tokenServerUrl: "http://localhost" +tokenServerUrl: "http://localhost/token" userNameField: "userNameField"