From 6e17311c71e93bb396d43c0213d60e3629a97f2c Mon Sep 17 00:00:00 2001 From: James Nord Date: Wed, 18 Sep 2024 18:26:27 +0100 Subject: [PATCH] Replace EOL Google Oauth library This changes the Google OAuth library which is in maintainance mode with a supported library (nimbusds via pac4j) The library requires that the Issuer is set to enforce security and there is no option to disable this requirement as it is mandated in the specificiation. As such users must first update to 4.355.v3a_fb_fca_b_96d4 to set the Issuer before updating to this version. fixes: #313 --- pom.xml | 106 ++- .../oic/AnythingGoesTokenValidator.java | 70 ++ .../oic/JenkinsAwareConnectionFactory.java | 33 - .../plugins/oic/OicJsonWebTokenVerifier.java | 106 --- .../plugins/oic/OicSecurityRealm.java | 675 +++++++++--------- .../plugins/oic/OicServerConfiguration.java | 30 +- .../oic/OicServerManualConfiguration.java | 102 ++- .../oic/OicServerWellKnownConfiguration.java | 221 +++--- .../org/jenkinsci/plugins/oic/OicSession.java | 271 ------- .../plugins/oic/OicTokenResponse.java | 157 ---- .../oic/ProxyAwareResourceRetriever.java | 76 ++ .../WellKnownOpenIDConfigurationResponse.java | 160 ----- .../oic/ssl/AnythingGoesTrustManager.java | 34 + .../oic/ssl/IgnoringHostNameVerifier.java | 25 + .../jenkinsci/plugins/oic/ssl/TLSUtils.java | 29 + .../jenkinsci/plugins/oic/Messages.properties | 3 + .../OicSecurityRealm/help-clientSecret.html | 5 +- .../OicSecurityRealm/help-subjectType.html | 4 + .../OicServerManualConfiguration/config.jelly | 5 + .../config.properties | 1 + .../help-issuer.html | 3 + .../help-issuer_fr.html | 3 + .../plugins/oic/ConfigurationAsCodeTest.java | 77 +- .../org/jenkinsci/plugins/oic/FieldTest.java | 10 +- .../JenkinsAwareConnectionFactoryTest.java | 36 - .../oic/OicJsonWebTokenVerifierTest.java | 183 ----- .../OicServerWellKnownConfigurationTest.java | 18 +- .../jenkinsci/plugins/oic/OicSessionTest.java | 77 -- .../plugins/oic/OicTokenResponseTest.java | 93 --- .../org/jenkinsci/plugins/oic/PluginTest.java | 119 +-- .../oic/ProxyAwareResourceRetrieverTest.java | 47 ++ .../org/jenkinsci/plugins/oic/TestRealm.java | 44 +- ...lKnownOpenIDConfigurationResponseTest.java | 166 ----- .../plugins/oic/ConfigurationAsCode.yml | 1 + .../plugins/oic/ConfigurationAsCodeExport.yml | 1 + .../oic/ConfigurationAsCodeMinimal.yml | 1 + 36 files changed, 1058 insertions(+), 1934 deletions(-) create mode 100644 src/main/java/org/jenkinsci/plugins/oic/AnythingGoesTokenValidator.java delete mode 100644 src/main/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactory.java delete mode 100644 src/main/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifier.java delete mode 100644 src/main/java/org/jenkinsci/plugins/oic/OicSession.java delete mode 100644 src/main/java/org/jenkinsci/plugins/oic/OicTokenResponse.java create mode 100644 src/main/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetriever.java delete mode 100644 src/main/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponse.java create mode 100644 src/main/java/org/jenkinsci/plugins/oic/ssl/AnythingGoesTrustManager.java create mode 100644 src/main/java/org/jenkinsci/plugins/oic/ssl/IgnoringHostNameVerifier.java create mode 100644 src/main/java/org/jenkinsci/plugins/oic/ssl/TLSUtils.java create mode 100644 src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-subjectType.html create mode 100644 src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer.html create mode 100644 src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer_fr.html delete mode 100644 src/test/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactoryTest.java delete mode 100644 src/test/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifierTest.java delete mode 100644 src/test/java/org/jenkinsci/plugins/oic/OicSessionTest.java delete mode 100644 src/test/java/org/jenkinsci/plugins/oic/OicTokenResponseTest.java create mode 100644 src/test/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetrieverTest.java delete mode 100644 src/test/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponseTest.java diff --git a/pom.xml b/pom.xml index f4315b86..5b32b986 100644 --- a/pom.xml +++ b/pom.xml @@ -45,21 +45,85 @@ 4 999999-SNAPSHOT jenkinsci/${project.artifactId}-plugin + 2.426.3 false Max 1836.vccda_4a_122a_a_e 4.347 + + 5.7.5 + + + + + io.jenkins.tools.bom + bom-2.426.x + 3208.vb_21177d4b_cd9 + pom + import + + + + com.github.stephenc.jcip + jcip-annotations + provided + + + + - com.google.http-client - google-http-client - 1.45.0 + io.burt + jmespath-core + 0.6.0 + + + io.jenkins.plugins + asm-api + + + org.jenkins-ci.plugins + jackson2-api + + + org.jenkins-ci.plugins + mailer + + + + org.pac4j + + pac4j-javaee + ${pac4jVersion} + + + com.google.guava + guava + + + + org.ow2.asm + asm + + + + org.slf4j + slf4j-api + + + + + org.pac4j + pac4j-oidc + ${pac4jVersion} + - com.google.errorprone - error_prone_annotations + + com.fasterxml.jackson.core + jackson-databind com.google.guava @@ -68,15 +132,16 @@ - com.google.http-client - google-http-client-gson - 1.45.0 + com.google.code.gson + gson + 2.11.0 + test - com.google.oauth-client google-oauth-client 1.36.0 + test com.google.guava @@ -84,26 +149,9 @@ - - io.burt - jmespath-core - 0.6.0 - - - org.jenkins-ci.plugins - mailer - 448.v5b_97805e3767 - - - com.github.tomakehurst - wiremock-standalone - 2.27.2 - test - io.jenkins.configuration-as-code test-harness - ${configuration-as-code.version} test @@ -122,6 +170,12 @@ mockito-junit-jupiter test + + org.wiremock + wiremock-standalone + 3.9.1 + test + diff --git a/src/main/java/org/jenkinsci/plugins/oic/AnythingGoesTokenValidator.java b/src/main/java/org/jenkinsci/plugins/oic/AnythingGoesTokenValidator.java new file mode 100644 index 00000000..2bdb56ca --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/oic/AnythingGoesTokenValidator.java @@ -0,0 +1,70 @@ +package org.jenkinsci.plugins.oic; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.openid.connect.sdk.Nonce; +import com.nimbusds.openid.connect.sdk.SubjectType; +import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.pac4j.core.exception.TechnicalException; +import org.pac4j.oidc.config.OidcConfiguration; +import org.pac4j.oidc.profile.creator.TokenValidator; + +public class AnythingGoesTokenValidator extends TokenValidator { + + public static final Logger LOGGER = Logger.getLogger(AnythingGoesTokenValidator.class.getName()); + + public AnythingGoesTokenValidator() { + super(createFakeOidcConfiguration()); + } + + @Override + public IDTokenClaimsSet validate(final JWT idToken, final Nonce expectedNonce) { + // validation is disabled, so everything is valid. + try { + return new IDTokenClaimsSet(idToken.getJWTClaimsSet()); + } catch (ParseException | java.text.ParseException e) { + LOGGER.log( + Level.WARNING, + "Token validation is disabled, but the token is corrupt and claims will not be represted.", + e); + try { + return new IDTokenClaimsSet(new JWTClaimsSet.Builder().build()); + } catch (ParseException e1) { + throw new TechnicalException("could not create and empty IDTokenClaimsSet"); + } + } + } + + /** + * Annoyingly the super class needs an OidcConfiguration with some values set, + * which if we are not validating we may not actually have (e.g. jwks_url). + * So we need a configuration with this set just so the validator can say "this is valid". + */ + private static OidcConfiguration createFakeOidcConfiguration() { + try { + OidcConfiguration config = new OidcConfiguration(); + config.setClientId("ignored"); + config.setSecret("ignored"); + OIDCProviderMetadata providerMetadata = new OIDCProviderMetadata( + new Issuer("http://ignored"), List.of(SubjectType.PUBLIC), new URI("http://ignored.and.invalid./")); + providerMetadata.setIDTokenJWSAlgs(List.of(JWSAlgorithm.HS256)); + config.setProviderMetadata(providerMetadata); + config.setPreferredJwsAlgorithm(JWSAlgorithm.HS256); + config.setClientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + return config; + } catch (URISyntaxException e) { + // should never happen the urls we are using are valid + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactory.java b/src/main/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactory.java deleted file mode 100644 index 227d642d..00000000 --- a/src/main/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactory.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.jenkinsci.plugins.oic; - -import com.google.api.client.http.javanet.ConnectionFactory; -import edu.umd.cs.findbugs.annotations.NonNull; -import hudson.ProxyConfiguration; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import jenkins.model.Jenkins; - -/** - * This Factory for {@link HttpURLConnection} honors the jenkins (proxy) settings when creating connections - * - * @see com.google.api.client.http.javanet.DefaultConnectionFactory - * This class uses, instead of a proxy object passed to the constructor, the jenkins {@link ProxyConfiguration} (Jenkins.getInstance().proxy) settings when available. - * @author Michael Bischoff - */ -public class JenkinsAwareConnectionFactory implements ConnectionFactory { - - public JenkinsAwareConnectionFactory() {} - - @Override - public HttpURLConnection openConnection(@NonNull URL url) throws IOException, ClassCastException { - Jenkins jenkins = Jenkins.get(); - if (jenkins != null) { - ProxyConfiguration proxyConfig = jenkins.proxy; - if (proxyConfig != null) { - return (HttpURLConnection) url.openConnection(proxyConfig.createProxy(url.getHost())); - } - } - return (HttpURLConnection) url.openConnection(); - } -} diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifier.java b/src/main/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifier.java deleted file mode 100644 index 0786b6db..00000000 --- a/src/main/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifier.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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; -import java.util.logging.Logger; - -/** - * Extend IdTokenVerifier to verify UserInfo webtoken - */ -public class OicJsonWebTokenVerifier extends IdTokenVerifier { - - private static final Logger LOGGER = Logger.getLogger(OicJsonWebTokenVerifier.class.getName()); - - /** Bypass Signature verification if JWKS url is not available */ - private boolean jwksServerUrlAvailable; - - /** Payload indicating userInfo */ - private static final IdToken.Payload NO_PAYLOAD = new IdToken.Payload(); - - /** - * Default verifier - */ - public OicJsonWebTokenVerifier() { - super(); - jwksServerUrlAvailable = false; - } - - /** - * Verifier with custom builder - */ - public OicJsonWebTokenVerifier(String jwksServerUrl, IdTokenVerifier.Builder builder) { - super(builder.setCertificatesLocation(jwksServerUrl)); - jwksServerUrlAvailable = (Util.fixEmptyAndTrim(jwksServerUrl) != null); - } - - /** JWKS verfication enabled - for tests only */ - public boolean isJwksServerUrlAvailable() { - return jwksServerUrlAvailable; - } - - /** Verify real idtoken */ - public boolean verifyIdToken(IdToken idToken) throws IOException { - if (isJwksServerUrlAvailable()) { - try { - return verifyOrThrow(idToken); - } catch (IOException e) { - LOGGER.warning("IdToken signature verification failed '" + e.toString() - + "' - jwks signature verification disabled"); - jwksServerUrlAvailable = false; - } - } - return super.verifyPayload(idToken); - } - - /** Verify userinfo jwt token */ - public boolean verifyUserInfo(JsonWebSignature userinfo) throws IOException { - if (isJwksServerUrlAvailable()) { - try { - IdToken idToken = new IdToken( - userinfo.getHeader(), - NO_PAYLOAD, /* bypass verification of payload */ - userinfo.getSignatureBytes(), - userinfo.getSignedContentBytes()); - return verifyOrThrow(idToken); - } catch (IOException e) { - LOGGER.warning("UserInfo signature verification failed '" + e.toString() + "' - ignore"); - } - } - return true; - } - - /** 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 488f33fd..f1b248cc 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java @@ -23,40 +23,23 @@ */ package org.jenkinsci.plugins.oic; -import com.google.api.client.auth.oauth2.AuthorizationCodeFlow; -import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest; -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.oauth2.RefreshTokenRequest; -import com.google.api.client.auth.oauth2.TokenErrorResponse; -import com.google.api.client.auth.oauth2.TokenResponseException; -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; -import com.google.api.client.http.HttpExecuteInterceptor; -import com.google.api.client.http.HttpRequest; -import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpRequestInitializer; -import com.google.api.client.http.HttpResponseException; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.GenericJson; -import com.google.api.client.json.JsonObjectParser; -import com.google.api.client.json.gson.GsonFactory; -import com.google.api.client.json.webtoken.JsonWebSignature; -import com.google.api.client.util.ArrayMap; -import com.google.api.client.util.Data; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.nimbusds.jwt.JWT; +import com.nimbusds.oauth2.sdk.GrantType; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod; +import com.nimbusds.oauth2.sdk.token.AccessToken; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.oauth2.sdk.token.RefreshToken; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; import edu.umd.cs.findbugs.annotations.CheckForNull; -import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.Extension; import hudson.Util; import hudson.model.Descriptor; import hudson.model.Descriptor.FormException; +import hudson.model.Failure; import hudson.model.User; import hudson.security.ChainedServletFilter; import hudson.security.SecurityRealm; @@ -71,14 +54,16 @@ import java.io.InvalidObjectException; import java.io.ObjectStreamException; import java.io.Serializable; +import java.net.HttpURLConnection; import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; +import java.text.ParseException; import java.time.Clock; import java.util.ArrayList; -import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; @@ -88,6 +73,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; +import javax.annotation.PostConstruct; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; @@ -100,19 +86,35 @@ import jenkins.model.Jenkins; import jenkins.security.ApiTokenProperty; import jenkins.security.SecurityListener; +import org.apache.commons.lang.StringUtils; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.DoNotUse; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.Header; -import org.kohsuke.stapler.HttpRedirect; import org.kohsuke.stapler.HttpResponse; -import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.Stapler; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.interceptor.RequirePOST; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.exception.TechnicalException; +import org.pac4j.core.exception.http.HttpAction; +import org.pac4j.core.exception.http.RedirectionAction; +import org.pac4j.core.http.callback.NoParameterCallbackUrlResolver; +import org.pac4j.core.profile.creator.ProfileCreator; +import org.pac4j.jee.context.JEEContextFactory; +import org.pac4j.jee.context.session.JEESessionStoreFactory; +import org.pac4j.jee.http.adapter.JEEHttpActionAdapter; +import org.pac4j.oidc.client.OidcClient; +import org.pac4j.oidc.config.OidcConfiguration; +import org.pac4j.oidc.credentials.authenticator.OidcAuthenticator; +import org.pac4j.oidc.profile.OidcProfile; +import org.pac4j.oidc.redirect.OidcRedirectionActionBuilder; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -139,13 +141,24 @@ public class OicSecurityRealm extends SecurityRealm implements Serializable { private static final Logger LOGGER = Logger.getLogger(OicSecurityRealm.class.getName()); public static enum TokenAuthMethod { - client_secret_basic, - client_secret_post + client_secret_basic(ClientAuthenticationMethod.CLIENT_SECRET_BASIC), + client_secret_post(ClientAuthenticationMethod.CLIENT_SECRET_POST); + + private ClientAuthenticationMethod clientAuthMethod; + + TokenAuthMethod(ClientAuthenticationMethod clientAuthMethod) { + this.clientAuthMethod = clientAuthMethod; + } + + ClientAuthenticationMethod toClientAuthenticationMethod() { + return clientAuthMethod; + } }; private static final String ID_TOKEN_REQUEST_ATTRIBUTE = "oic-id-token"; private static final String STATE_REQUEST_ATTRIBUTE = "oic-state"; private static final String NO_SECRET = "none"; + private static final String SESSION_POST_LOGIN_REDIRECT_URL_KEY = "oic-redirect-on-login-url"; private final String clientId; private final Secret clientSecret; @@ -259,12 +272,6 @@ public static enum TokenAuthMethod { @Deprecated private transient String endSessionUrl; - /** Verification of IdToken and UserInfo (in jwt case) - */ - private transient OicJsonWebTokenVerifier jwtVerifier; - - private transient HttpTransport httpTransport = null; - /** Random generator needed for robust random wait */ private static final Random RANDOM = new Random(); @@ -278,6 +285,11 @@ public static enum TokenAuthMethod { private static final JmesPath JMESPATH = new JcfRuntime( new RuntimeConfiguration.Builder().withSilentTypeErrors(true).build()); + /** + * Resource retriever configured with an appropriate SSL Factory based on {@link #isDisableSslVerification()} + */ + private transient ProxyAwareResourceRetriever proxyAwareResourceRetriever; + @DataBoundConstructor public OicSecurityRealm( String clientId, @@ -287,7 +299,6 @@ public OicSecurityRealm( throws IOException { // Needed in DataBoundSetter this.disableSslVerification = Util.fixNull(disableSslVerification, Boolean.FALSE); - this.httpTransport = constructHttpTransport(this.disableSslVerification); this.clientId = clientId; this.clientSecret = clientSecret; this.serverConfiguration = serverConfiguration; @@ -295,9 +306,6 @@ public OicSecurityRealm( @SuppressWarnings("deprecated") protected Object readResolve() throws ObjectStreamException { - if (httpTransport == null) { - httpTransport = constructHttpTransport(isDisableSslVerification()); - } if (!Strings.isNullOrEmpty(endSessionUrl)) { this.endSessionEndpoint = endSessionUrl + "/"; } @@ -327,8 +335,8 @@ protected Object readResolve() throws ObjectStreamException { conf.setScopesOverride(this.overrideScopes); serverConfiguration = conf; } else { - OicServerManualConfiguration conf = - new OicServerManualConfiguration(tokenServerUrl, authorizationServerUrl); + OicServerManualConfiguration conf = new OicServerManualConfiguration( + /* TODO */ "migrated", tokenServerUrl, authorizationServerUrl); if (tokenAuthMethod != null) { conf.setTokenAuthMethod(tokenAuthMethod); } @@ -348,34 +356,10 @@ protected Object readResolve() throws ObjectStreamException { ose.initCause(e); throw ose; } + createProxyAwareResourceRetriver(); return this; } - static HttpTransport constructHttpTransport(boolean disableSslVerification) { - NetHttpTransport.Builder builder = new NetHttpTransport.Builder(); - builder.setConnectionFactory(new JenkinsAwareConnectionFactory()); - - if (disableSslVerification) { - try { - builder.doNotValidateCertificate(); - } catch (GeneralSecurityException ex) { - // we do not handle this exception... - } - } - - return builder.build(); - } - - /** - * Obtain the shared HttpTransport. - * The transport may be invalidated if the realm is saved so should not be cached. - * @return the shared {@code HttpTransport}. - */ - @Restricted(NoExternalUse.class) - HttpTransport getHttpTransport() { - return httpTransport; - } - public String getClientId() { return clientId; } @@ -473,6 +457,62 @@ public Long getAllowedTokenExpirationClockSkewSeconds() { return allowedTokenExpirationClockSkewSeconds; } + @PostConstruct + @Restricted(NoExternalUse.class) + public void createProxyAwareResourceRetriver() { + proxyAwareResourceRetriever = + ProxyAwareResourceRetriever.createProxyAwareResourceRetriver(isDisableSslVerification()); + } + + ProxyAwareResourceRetriever getResourceRetriever() { + return proxyAwareResourceRetriever; + } + + private OidcConfiguration buildOidcConfiguration() { + // TODO cache this and use the well known if available. + OidcConfiguration conf = new OidcConfiguration(); + conf.setClientId(clientId); + conf.setSecret(clientSecret.getPlainText()); + + // TODO what do we prefer? + // conf.setPreferredJwsAlgorithm(JWSAlgorithm.HS256); + // set many more as needed... + + OIDCProviderMetadata oidcProviderMetadata = serverConfiguration.toProviderMetadata(); + if (this.isDisableTokenVerification()) { + conf.setAllowUnsignedIdTokens(true); + conf.setTokenValidator(new AnythingGoesTokenValidator()); + } + conf.setProviderMetadata(oidcProviderMetadata); + if (oidcProviderMetadata.getScopes() != null) { + // auto configuration does not need to supply scopes + conf.setScope(oidcProviderMetadata.getScopes().toString()); + } + conf.setUseNonce(!this.nonceDisabled); + if (allowedTokenExpirationClockSkewSeconds != null) { + conf.setMaxClockSkew(allowedTokenExpirationClockSkewSeconds.intValue()); + } + conf.setResourceRetriever(getResourceRetriever()); + if (this.isPkceEnabled()) { + conf.setPkceMethod(CodeChallengeMethod.S256); + } + return conf; + } + + @Restricted(NoExternalUse.class) // exposed for testing only + protected OidcClient buildOidcClient() { + OidcConfiguration oidcConfiguration = buildOidcConfiguration(); + OidcClient client = new OidcClient(oidcConfiguration); + // add the extra settings for the client... + client.setCallbackUrl(buildOAuthRedirectUrl()); + client.setAuthenticator(new OidcAuthenticator(oidcConfiguration, client)); + // when building the redirect URL by default pac4j adds the "client_name=DOidcClient" query parameter to the + // redirectURL. + // OPs will reject this for existing clients as the redirect URL is not the same as previously configured + client.setCallbackUrlResolver(new NoParameterCallbackUrlResolver()); + return client; + } + @DataBoundSetter public void setUserNameField(String userNameField) { this.userNameField = Util.fixNull(Util.fixEmptyAndTrim(userNameField), "sub"); @@ -521,8 +561,8 @@ protected static Expression compileJMESPath(String str, String logCommen return null; } - private Object applyJMESPath(Expression expression, GenericJson json) { - return expression.search(json); + private Object applyJMESPath(Expression expression, Object map) { + return expression.search(map); } @DataBoundSetter @@ -681,29 +721,6 @@ public Authentication authenticate(Authentication authentication) throws Authent }); } - /** Build authorization code flow - */ - protected AuthorizationCodeFlow buildAuthorizationCodeFlow() { - AccessMethod tokenAccessMethod = BearerToken.queryParameterAccessMethod(); - HttpExecuteInterceptor authInterceptor = - new ClientParametersAuthentication(clientId, Secret.toString(clientSecret)); - if (TokenAuthMethod.client_secret_basic.equals(serverConfiguration.getTokenAuthMethod())) { - tokenAccessMethod = BearerToken.authorizationHeaderAccessMethod(); - authInterceptor = new BasicAuthentication(clientId, Secret.toString(clientSecret)); - } - AuthorizationCodeFlow.Builder builder = new AuthorizationCodeFlow.Builder( - tokenAccessMethod, - httpTransport, - GsonFactory.getDefaultInstance(), - new GenericUrl(serverConfiguration.getTokenServerUrl()), - authInterceptor, - clientId, - serverConfiguration.getAuthorizationServerUrl()) - .setScopes(Arrays.asList(serverConfiguration.getScopes())); - - return builder.build(); - } - /** * Validate post-login redirect URL * @@ -737,118 +754,28 @@ protected String getValidRedirectUrl(String url) { * @param from the relative URL to the page that the user has just come from * @param referer the HTTP referer header (where to redirect the user back to after login has finished) * @return an {@link HttpResponse} object + * @throws URISyntaxException if the provided data is invalid */ @Restricted(DoNotUse.class) // stapler only - public HttpResponse doCommenceLogin(@QueryParameter String from, @Header("Referer") final String referer) { - final String redirectOnFinish = getValidRedirectUrl(from != null ? from : referer); - - return new OicSession(from, buildOAuthRedirectUrl()) { - @Override - public HttpResponse onSuccess(String authorizationCode, AuthorizationCodeFlow flow) { - try { - AuthorizationCodeTokenRequest tokenRequest = flow.newTokenRequest(authorizationCode) - .setRedirectUri(buildOAuthRedirectUrl()) - .setResponseClass(OicTokenResponse.class); - if (this.pkceVerifierCode != null) { - tokenRequest.set("code_verifier", this.pkceVerifierCode); - } - if (!sendScopesInTokenRequest) { - tokenRequest.setScopes(Collections.emptyList()); - } - - OicTokenResponse response = (OicTokenResponse) tokenRequest.execute(); - - if (response.getIdToken() == null) { - return HttpResponses.errorWithoutStack(500, Messages.OicSecurityRealm_NoIdTokenInResponse()); - } - IdToken idToken; - try { - idToken = response.parseIdToken(); - } 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"); - } - - if (failedCheckOfTokenField(idToken)) { - throw new FailedCheckOfTokenException( - maybeOpenIdLogoutEndpoint(response.getIdToken(), state, buildOauthCommenceLogin())); - } - - GenericJson userInfo = null; - if (!Strings.isNullOrEmpty(getServerConfiguration().getUserInfoServerUrl())) { - userInfo = getUserInfo(flow, response.getAccessToken()); - if (userInfo == null) { - return HttpResponses.errorWithoutStack(401, "Unauthorized"); - } - } - - String username = determineStringField(userNameFieldExpr, idToken, userInfo); - if (username == null) { - return HttpResponses.error(500, Messages.OicSecurityRealm_UsernameNotFound(userNameField)); - } - - flow.createAndStoreCredential(response, null); + public void doCommenceLogin(@QueryParameter String from, @Header("Referer") final String referer) + throws URISyntaxException { - OicCredentials credentials = new OicCredentials( - response.getAccessToken(), - response.getIdToken(), - response.getRefreshToken(), - response.getExpiresInSeconds(), - CLOCK.millis(), - OicSecurityRealm.this.getAllowedTokenExpirationClockSkewSeconds()); - - loginAndSetUserData(username.toString(), idToken, userInfo, credentials); - - return new HttpRedirect(redirectOnFinish); - - } catch (IOException e) { - return HttpResponses.error(500, Messages.OicSecurityRealm_TokenRequestFailure(e)); - } - } - }.withNonceDisabled(isNonceDisabled()) - .withPkceEnabled(isPkceEnabled()) - .commenceLogin(buildAuthorizationCodeFlow()); - } - - /** Create OicJsonWebTokenVerifier if needed */ - private OicJsonWebTokenVerifier getJwksVerifier() { - if (isDisableTokenVerification()) { - return null; - } - if (jwtVerifier == null) { - jwtVerifier = new OicJsonWebTokenVerifier( - serverConfiguration.getJwksServerUrl(), - new OicJsonWebTokenVerifier.Builder().setHttpTransportFactory(new HttpTransportFactory() { - @Override - public HttpTransport create() { - return httpTransport; - } - })); - } - return jwtVerifier; - } + OidcClient client = buildOidcClient(); + // add the extra params for the client... + final String redirectOnFinish = getValidRedirectUrl(from != null ? from : referer); + client.setCallbackUrl(buildOAuthRedirectUrl()); - /** Validate UserInfo signature if available */ - private boolean validateUserInfo(JsonWebSignature userinfo) throws IOException { - OicJsonWebTokenVerifier verifier = getJwksVerifier(); - if (verifier == null) { - return true; - } - return verifier.verifyUserInfo(userinfo); - } + OidcRedirectionActionBuilder builder = new OidcRedirectionActionBuilder(client); + WebContext webContext = + JEEContextFactory.INSTANCE.newContext(Stapler.getCurrentRequest(), Stapler.getCurrentResponse()); + SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore(); + RedirectionAction redirectionAction = + builder.getRedirectionAction(webContext, sessionStore).orElseThrow(); - /** Validate IdToken signature if available */ - private boolean validateIdToken(IdToken idtoken) throws IOException { - OicJsonWebTokenVerifier verifier = getJwksVerifier(); - if (verifier == null) { - return true; - } - return verifier.verifyIdToken(idtoken); + // store the redirect url for after the login. + sessionStore.set(webContext, SESSION_POST_LOGIN_REDIRECT_URL_KEY, redirectOnFinish); + JEEHttpActionAdapter.INSTANCE.adapt(redirectionAction, webContext); + return; } @SuppressFBWarnings( @@ -863,52 +790,23 @@ private void randomWait() { } } - private GenericJson getUserInfo(final AuthorizationCodeFlow flow, final String accessToken) throws IOException { - HttpRequestFactory requestFactory = flow.getTransport().createRequestFactory(new HttpRequestInitializer() { - @Override - public void initialize(HttpRequest request) throws IOException { - request.getHeaders().setAuthorization("Bearer " + accessToken); - } - }); - HttpRequest request = - requestFactory.buildGetRequest(new GenericUrl(serverConfiguration.getUserInfoServerUrl())); - request.setThrowExceptionOnExecuteError(false); - com.google.api.client.http.HttpResponse response = request.execute(); - if (response.isSuccessStatusCode()) { - 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(); - } - - JsonObjectParser parser = new JsonObjectParser(flow.getJsonFactory()); - return parser.parseAndClose(response.getContent(), response.getContentCharset(), GenericJson.class); - } - throw new HttpResponseException(response); - } - - private boolean failedCheckOfTokenField(IdToken idToken) { + private boolean failedCheckOfTokenField(JWT idToken) throws ParseException { if (tokenFieldToCheckKey == null || tokenFieldToCheckValue == null) { return false; } - if (idToken == null) { return true; } - - String value = getStringField(idToken.getPayload(), tokenFieldToCheckExpr); + String value = getStringField(idToken.getJWTClaimsSet().getClaims(), tokenFieldToCheckExpr); if (value == null) { return true; } - return !tokenFieldToCheckValue.equals(value); } private UsernamePasswordAuthenticationToken loginAndSetUserData( - String userName, IdToken idToken, GenericJson userInfo, OicCredentials credentials) throws IOException { + String userName, JWT idToken, Map userInfo, OicCredentials credentials) + throws IOException, ParseException { List grantedAuthorities = determineAuthorities(idToken, userInfo); if (LOGGER.isLoggable(Level.FINEST)) { @@ -950,7 +848,7 @@ private UsernamePasswordAuthenticationToken loginAndSetUserData( return token; } - private String determineStringField(Expression fieldExpr, IdToken idToken, GenericJson userInfo) { + private String determineStringField(Expression fieldExpr, JWT idToken, Map userInfo) throws ParseException { if (fieldExpr != null) { if (userInfo != null) { Object field = fieldExpr.search(userInfo); @@ -962,7 +860,8 @@ private String determineStringField(Expression fieldExpr, IdToken idToke } } if (idToken != null) { - String fieldValue = Util.fixEmptyAndTrim(getStringField(idToken.getPayload(), fieldExpr)); + String fieldValue = Util.fixEmptyAndTrim( + getStringField(idToken.getJWTClaimsSet().getClaims(), fieldExpr)); if (fieldValue != null) { return fieldValue; } @@ -981,7 +880,8 @@ protected String getStringField(Object object, Expression fieldExpr) { return null; } - private List determineAuthorities(IdToken idToken, GenericJson userInfo) { + private List determineAuthorities(JWT idToken, Map userInfo) + throws ParseException { List grantedAuthorities = new ArrayList<>(); grantedAuthorities.add(SecurityRealm.AUTHENTICATED_AUTHORITY2); if (this.groupsFieldExpr == null) { @@ -1000,7 +900,7 @@ private List determineAuthorities(IdToken idToken, GenericJson groupsObject = this.groupsFieldExpr.search(userInfo); } if (groupsObject == null && idToken != null) { - groupsObject = this.groupsFieldExpr.search(idToken.getPayload()); + groupsObject = this.groupsFieldExpr.search(idToken.getJWTClaimsSet().getClaims()); } if (groupsObject == null) { LOGGER.warning("idToken and userInfo did not contain group field name: " + this.groupsFieldName); @@ -1025,7 +925,7 @@ private List determineAuthorities(IdToken idToken, GenericJson /** Ensure group field object returns is string or list of string */ private List ensureString(Object field) { - if (field == null || Data.isNull(field)) { + if (field == null) { LOGGER.warning("userInfo did not contain a valid group field content, got null"); return Collections.emptyList(); } else if (field instanceof String) { @@ -1041,13 +941,13 @@ private List ensureString(Object field) { } } return result; - } else if (field instanceof ArrayList) { + } else if (field instanceof List) { List result = new ArrayList<>(); List groups = (List) field; for (Object group : groups) { if (group instanceof String) { result.add(group.toString()); - } else if (group instanceof ArrayMap) { + } else if (group instanceof Map) { // if its a Map, we use the nestedGroupFieldName to grab the groups Map groupMap = (Map) group; if (nestedGroupFieldName != null && groupMap.keySet().contains(nestedGroupFieldName)) { @@ -1077,7 +977,8 @@ public void doLogout(StaplerRequest req, StaplerResponse rsp) throws IOException OicCredentials credentials = user.getProperty(OicCredentials.class); if (credentials != null) { - if (this.logoutFromOpenidProvider && !Strings.isNullOrEmpty(serverConfiguration.getEndSessionUrl())) { + if (this.logoutFromOpenidProvider + && serverConfiguration.toProviderMetadata().getEndSessionEndpointURI() != null) { // This ensures that token will be expired at the right time with API Key calls, but no refresh can be // made. user.addProperty(new OicCredentials(null, null, null, CLOCK.millis())); @@ -1089,10 +990,6 @@ public void doLogout(StaplerRequest req, StaplerResponse rsp) throws IOException super.doLogout(req, rsp); } - static void ensureStateAttribute(@NonNull HttpSession session, @NonNull String state) { - session.setAttribute(STATE_REQUEST_ATTRIBUTE, state); - } - @Override public String getPostLogOutUrl2(StaplerRequest req, Authentication auth) { Object idToken = req.getAttribute(ID_TOKEN_REQUEST_ATTRIBUTE); @@ -1106,15 +1003,23 @@ public String getPostLogOutUrl2(StaplerRequest req, Authentication auth) { } @VisibleForTesting - static Object getStateAttribute(HttpSession session) { - return session.getAttribute(STATE_REQUEST_ATTRIBUTE); + Object getStateAttribute(HttpSession session) { + // return null; + OidcClient client = buildOidcClient(); + WebContext webContext = + JEEContextFactory.INSTANCE.newContext(Stapler.getCurrentRequest(), Stapler.getCurrentResponse()); + SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore(); + return client.getConfiguration() + .getValueRetriever() + .retrieve(client.getStateSessionAttributeName(), client, webContext, sessionStore) + .orElse(null); } @CheckForNull private String maybeOpenIdLogoutEndpoint(String idToken, String state, String postLogoutRedirectUrl) { - final String url = serverConfiguration.getEndSessionUrl(); - if (this.logoutFromOpenidProvider && !Strings.isNullOrEmpty(url)) { - StringBuilder openidLogoutEndpoint = new StringBuilder(url); + final URI url = serverConfiguration.toProviderMetadata().getEndSessionEndpointURI(); + if (this.logoutFromOpenidProvider && url != null) { + StringBuilder openidLogoutEndpoint = new StringBuilder(url.toString()); if (!Strings.isNullOrEmpty(idToken)) { openidLogoutEndpoint.append("?id_token_hint=").append(idToken).append("&"); @@ -1168,15 +1073,62 @@ private String buildOAuthRedirectUrl() throws NullPointerException { /** * This is where the user comes back to at the end of the OpenID redirect ping-pong. * @param request The user's request - * @return an HttpResponse + * @throws ParseException + * @throws URISyntaxException */ - public HttpResponse doFinishLogin(StaplerRequest request) throws IOException { - OicSession currentSession = OicSession.getCurrent(); - if (currentSession == null) { - LOGGER.fine("No session to resume (perhaps jenkins was restarted?)"); - return HttpResponses.errorWithoutStack(401, "Unauthorized"); + public void doFinishLogin(StaplerRequest request, StaplerResponse response) + throws IOException, ParseException, URISyntaxException { + OidcClient client = buildOidcClient(); + + WebContext webContext = JEEContextFactory.INSTANCE.newContext(request, response); + SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore(); + + try { + // NB: TODO this also handles back channel logout if "logoutendpoint" parameter is set + // see org.pac4j.oidc.credentials.extractor.OidcExtractor.extract(WebContext, SessionStore) + // but we probably need to hookup a special LogoutHandler in the clients configuration to do all the special + // Jenkins stuff correctly + // also should have its own URL to make the code easier to follow :) + + Credentials credentials = client.getCredentials(webContext, sessionStore) + .orElseThrow(() -> new Failure("Could not extract credentials from request")); + + ProfileCreator profileCreator = client.getProfileCreator(); + + // creating the profile performs validation of the token + OidcProfile profile = (OidcProfile) profileCreator + .create(credentials, webContext, sessionStore) + .orElseThrow(() -> new Failure("Could not build user profile")); + + AccessToken accessToken = profile.getAccessToken(); + JWT idToken = profile.getIdToken(); + RefreshToken refreshToken = profile.getRefreshToken(); + + String username = determineStringField(userNameFieldExpr, idToken, profile.getAttributes()); + if (failedCheckOfTokenField(idToken)) { + throw new FailedCheckOfTokenException(client.getConfiguration().findLogoutUrl()); + } + + OicCredentials oicCredentials = new OicCredentials( + accessToken == null ? null : accessToken.getValue(), // XXX (how) can the access token be null? + idToken.getParsedString(), + refreshToken != null ? refreshToken.getValue() : null, + accessToken == null ? 0 : accessToken.getLifetime(), + CLOCK.millis(), + getAllowedTokenExpirationClockSkewSeconds()); + + loginAndSetUserData(username, idToken, profile.getAttributes(), oicCredentials); + + String redirectUrl = (String) sessionStore + .get(webContext, SESSION_POST_LOGIN_REDIRECT_URL_KEY) + .orElse(Jenkins.get().getRootUrl()); + response.sendRedirect(HttpURLConnection.HTTP_MOVED_TEMP, redirectUrl); + + } catch (HttpAction e) { + // this may be an OK flow for logout login is handled upstream. + JEEHttpActionAdapter.INSTANCE.adapt(e, webContext); + return; } - return currentSession.finishLogin(request, buildAuthorizationCodeFlow()); } /** @@ -1224,7 +1176,8 @@ public boolean handleTokenExpiration(HttpServletRequest httpRequest, HttpServlet } if (isExpired(credentials)) { - if (serverConfiguration.isUseRefreshTokens() && !Strings.isNullOrEmpty(credentials.getRefreshToken())) { + if (serverConfiguration.toProviderMetadata().getGrantTypes().contains(GrantType.REFRESH_TOKEN) + && !Strings.isNullOrEmpty(credentials.getRefreshToken())) { return refreshExpiredToken(user.getId(), credentials, httpRequest, httpResponse); } else if (!isTokenExpirationCheckDisabled()) { redirectOrRejectRequest(httpRequest, httpResponse); @@ -1259,107 +1212,92 @@ private boolean refreshExpiredToken( HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException { - AuthorizationCodeFlow flow = buildAuthorizationCodeFlow(); - - RefreshTokenRequest request = new RefreshTokenRequest( - flow.getTransport(), - flow.getJsonFactory(), - new GenericUrl(flow.getTokenServerEncodedUrl()), - credentials.getRefreshToken()) - .setClientAuthentication(flow.getClientAuthentication()) - .setResponseClass(OicTokenResponse.class); + WebContext webContext = JEEContextFactory.INSTANCE.newContext(httpRequest, httpResponse); + SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore(); + OidcClient client = buildOidcClient(); try { - OicTokenResponse tokenResponse = (OicTokenResponse) request.execute(); - - LOGGER.log(Level.FINE, "Token refresh request", httpRequest.getRequestURI()); - - return handleTokenRefreshResponse(flow, expectedUsername, credentials, tokenResponse, httpResponse); - } catch (TokenResponseException e) { - handleTokenRefreshException(e, httpResponse); - return false; - } - } - - private boolean handleTokenRefreshResponse( - AuthorizationCodeFlow flow, - String expectedUsername, - OicCredentials credentials, - OicTokenResponse tokenResponse, - HttpServletResponse httpResponse) - throws IOException { - String refreshToken = tokenResponse.getRefreshToken(); - String idToken = tokenResponse.getIdToken(); - - // Refresh Token Flow is not required to send new ID or Refresh Token, so re-use if not received - if (idToken == null) { - idToken = credentials.getIdToken(); - tokenResponse.setIdToken(credentials.getIdToken()); - } - - if (refreshToken == null) { - refreshToken = credentials.getRefreshToken(); - } - - OicCredentials refreshedCredentials = new OicCredentials( - tokenResponse.getAccessToken(), - idToken, - refreshToken, - tokenResponse.getExpiresInSeconds(), - CLOCK.millis(), - getAllowedTokenExpirationClockSkewSeconds()); - - GenericJson userInfo = null; - IdToken parsedIdToken; - - try { - parsedIdToken = tokenResponse.parseIdToken(); - } catch (IllegalArgumentException e) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, Messages.OicSecurityRealm_IdTokenParseError()); - return false; - } + OidcProfile profile = new OidcProfile(); + // JSONObject json = (JSONObject) JSONUtils.parseJSON(credentials.getAccessToken()); + profile.setAccessToken(new BearerAccessToken(credentials.getAccessToken())); + profile.setIdTokenString(credentials.getIdToken()); + profile.setRefreshToken(new RefreshToken(credentials.getRefreshToken())); + + profile = (OidcProfile) client.renewUserProfile(profile, webContext, sessionStore) + .orElseThrow(() -> new IllegalStateException("Could not renew user profile")); + + /* + ((OidcAuthenticator) client.getAuthenticator()).refresh(creds); + // if we are successful we will have an access token + if (creds.getAccessToken() == null) { + LOGGER.log(Level.WARNING, "failed to refresh credentials, access token is null"); + return false; + } - if (!validateIdToken(parsedIdToken)) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); - return false; - } + // Refresh Token Flow is not required to send new ID or Refresh Token, so re-use if not received + if (creds.getIdToken() == null) { + creds.setIdToken(PlainJWT.parse(credentials.getIdToken())); + } else { + if (creds.getRefreshToken() == null) { + creds.setRefreshToken(new RefreshToken(credentials.getRefreshToken())); + } - if (failedCheckOfTokenField(parsedIdToken)) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); - return false; - } + ProfileCreator profileCreator = client.getProfileCreator(); + + // creating the profile performs validation of the token + OidcProfile profile = (OidcProfile) profileCreator + .create(creds, webContext, sessionStore) + .orElseThrow(() -> new TechnicalException("Could not build user profile")); + */ + + AccessToken accessToken = profile.getAccessToken(); + JWT idToken = profile.getIdToken(); + RefreshToken refreshToken = profile.getRefreshToken(); + String username = determineStringField(userNameFieldExpr, idToken, profile.getAttributes()); + if (!User.idStrategy().equals(expectedUsername, username)) { + httpResponse.sendError( + HttpServletResponse.SC_UNAUTHORIZED, "User name was not the same after refresh request"); + return false; + } - if (!Strings.isNullOrEmpty(serverConfiguration.getUserInfoServerUrl())) { - userInfo = getUserInfo(flow, tokenResponse.getAccessToken()); - } + if (failedCheckOfTokenField(idToken)) { + throw new FailedCheckOfTokenException(client.getConfiguration().findLogoutUrl()); + } - String username = determineStringField(userNameFieldExpr, parsedIdToken, userInfo); + OicCredentials refreshedCredentials = new OicCredentials( + accessToken.getValue(), + idToken.getParsedString(), + refreshToken.getValue(), + accessToken.getLifetime(), + CLOCK.millis(), + getAllowedTokenExpirationClockSkewSeconds()); - if (!User.idStrategy().equals(expectedUsername, username)) { + loginAndSetUserData(username, idToken, profile.getAttributes(), refreshedCredentials); + return true; + } catch (TechnicalException e) { + if (isTokenExpirationCheckDisabled() && StringUtils.contains(e.getMessage(), "error=invalid_grant")) { + // the code is lost from the TechnicalException so we need to restor to string matching to retain the + // same flow :-( + LOGGER.log( + Level.INFO, + "Failed to refresh expired token because grant is invalid, proceeding as \"Token Expiration Check Disabled\" is set"); + return false; + } + LOGGER.log(Level.WARNING, "Failed to refresh expired token", e); httpResponse.sendError( - HttpServletResponse.SC_UNAUTHORIZED, "User name was not the same after refresh request"); + HttpServletResponse.SC_UNAUTHORIZED, Messages.OicSecurityRealm_TokenRefreshFailure()); return false; - } - - loginAndSetUserData(username, parsedIdToken, userInfo, refreshedCredentials); - - return true; - } - - private void handleTokenRefreshException(TokenResponseException e, HttpServletResponse httpResponse) - throws IOException { - TokenErrorResponse details = e.getDetails(); - - if ("invalid_grant".equals(details.getError())) { - // RT expired or session terminated - if (!isTokenExpirationCheckDisabled()) { - httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token expired"); - } - } else { - LOGGER.warning("Token response error: " + details.getError() + ", error description: " - + details.getErrorDescription()); + } catch (ParseException e) { + LOGGER.log(Level.WARNING, "Failed to refresh expired token", e); + // could not renew httpResponse.sendError( - HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Token refresh error, check server logs"); + HttpServletResponse.SC_UNAUTHORIZED, Messages.OicSecurityRealm_TokenRefreshFailure()); + return false; + } catch (IllegalStateException e) { + LOGGER.log(Level.WARNING, "Failed to refresh expired token, profile was null", e); + // could not renew + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return false; } } @@ -1446,4 +1384,41 @@ public Descriptor getDefaultServerConfigurationType() { return Jenkins.get().getDescriptor(OicServerWellKnownConfiguration.class); } } + + /** + * Obtain {@code uri} as a string if it is not {@code null} otherwise returns the {@code defaultUri}. + * @param uri the possibly null URI + * @param defaultUri the URI to use if {@code uri} is null. + */ + private static String fixNullUri(URI uri, String defaultUri) { + if (uri != null) { + return uri.toASCIIString(); + } + return defaultUri; + } + + /** + * Obtain the first auth method that we support that is supported by the server. + */ + private static TokenAuthMethod fixTokenAuthMethod( + List supportedProviderMethods, TokenAuthMethod defaultProviderMethod) { + if (supportedProviderMethods == null || supportedProviderMethods.isEmpty()) { + return defaultProviderMethod; + } + return supportedProviderMethods.stream() + .map(OicSecurityRealm::toSupportedAuthMode) + .filter(Objects::nonNull) + .findFirst() + .orElse(defaultProviderMethod); + } + + private static TokenAuthMethod toSupportedAuthMode(ClientAuthenticationMethod auth) { + String value = auth.getValue(); + for (TokenAuthMethod tam : TokenAuthMethod.values()) { + if (tam.toString().equals(value)) { + return tam; + } + } + return null; + } } diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java b/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java index c543ff26..1251fd43 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java @@ -1,37 +1,17 @@ package org.jenkinsci.plugins.oic; -import edu.umd.cs.findbugs.annotations.CheckForNull; -import edu.umd.cs.findbugs.annotations.NonNull; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; import hudson.ExtensionPoint; import hudson.model.AbstractDescribableImpl; import java.io.Serializable; -import org.jenkinsci.plugins.oic.OicSecurityRealm.TokenAuthMethod; public abstract class OicServerConfiguration extends AbstractDescribableImpl implements ExtensionPoint, Serializable { private static final long serialVersionUID = 1L; - @NonNull - public abstract String getTokenServerUrl(); - - @NonNull - public abstract String getJwksServerUrl(); - - @NonNull - public abstract String getAuthorizationServerUrl(); - - @CheckForNull - public abstract String getUserInfoServerUrl(); - - @NonNull - public abstract String getScopes(); - - @NonNull - public abstract TokenAuthMethod getTokenAuthMethod(); - - @CheckForNull - public abstract String getEndSessionUrl(); - - public abstract boolean isUseRefreshTokens(); + /** + * Convert the OicServerConfiguration to {@link OIDCProviderMetadata} for use by the client. + */ + public abstract OIDCProviderMetadata toProviderMetadata(); } diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicServerManualConfiguration.java b/src/main/java/org/jenkinsci/plugins/oic/OicServerManualConfiguration.java index 9edb8592..8ed1ea46 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicServerManualConfiguration.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicServerManualConfiguration.java @@ -1,5 +1,14 @@ package org.jenkinsci.plugins.oic; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.oauth2.sdk.GrantType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.openid.connect.sdk.SubjectType; +import com.nimbusds.openid.connect.sdk.federation.registration.ClientRegistrationType; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; @@ -9,7 +18,11 @@ import hudson.model.Descriptor.FormException; import hudson.util.FormValidation; import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Objects; import jenkins.model.Jenkins; @@ -24,6 +37,7 @@ public class OicServerManualConfiguration extends OicServerConfiguration { private static final long serialVersionUID = 1L; + private final String issuer; private final String authorizationServerUrl; private final String tokenServerUrl; private TokenAuthMethod tokenAuthMethod = TokenAuthMethod.client_secret_post; @@ -34,7 +48,9 @@ public class OicServerManualConfiguration extends OicServerConfiguration { private boolean useRefreshTokens; @DataBoundConstructor - public OicServerManualConfiguration(String tokenServerUrl, String authorizationServerUrl) throws FormException { + public OicServerManualConfiguration(String issuer, String tokenServerUrl, String authorizationServerUrl) + throws FormException { + this.issuer = validateNonNull("issuer", issuer); this.authorizationServerUrl = validateNonNull("authorizationServerUrl", authorizationServerUrl); this.tokenServerUrl = validateNonNull("tokenServerUrl", tokenServerUrl); } @@ -69,47 +85,90 @@ public void setUseRefreshTokens(boolean useRefreshTokens) { this.useRefreshTokens = useRefreshTokens; } - @Override public String getAuthorizationServerUrl() { return authorizationServerUrl; } - @Override - @CheckForNull public String getEndSessionUrl() { return endSessionUrl; } - @Override + public String getIssuer() { + return issuer; + } + public boolean isUseRefreshTokens() { return useRefreshTokens; } - @Override public String getJwksServerUrl() { return jwksServerUrl; } - @Override public String getScopes() { return scopes; } - @Override public TokenAuthMethod getTokenAuthMethod() { return tokenAuthMethod; } - @Override public String getTokenServerUrl() { return tokenServerUrl; } - @Override public String getUserInfoServerUrl() { return userInfoServerUrl; } + @Override + public OIDCProviderMetadata toProviderMetadata() { + try { + final OIDCProviderMetadata providerMetadata; + if (jwksServerUrl == null) { + // will only work if token validation is disabled in the security realm. + providerMetadata = new OIDCProviderMetadata( + new Issuer(issuer), + List.of(SubjectType.PUBLIC), + List.of(ClientRegistrationType.AUTOMATIC), + null, + null, + new JWKSet()); + } else { + providerMetadata = new OIDCProviderMetadata( + new Issuer(issuer), List.of(SubjectType.PUBLIC), new URI(jwksServerUrl)); + } + if (isUseRefreshTokens()) { + providerMetadata.setGrantTypes(List.of(GrantType.REFRESH_TOKEN)); + } + + providerMetadata.setUserInfoEndpointURI(toURIOrNull(userInfoServerUrl)); + providerMetadata.setEndSessionEndpointURI(toURIOrNull(endSessionUrl)); + providerMetadata.setAuthorizationEndpointURI(new URI(authorizationServerUrl)); + providerMetadata.setTokenEndpointURI(toURIOrNull(tokenServerUrl)); + providerMetadata.setTokenEndpointAuthMethods(List.of(getClientAuthenticationMethod())); + providerMetadata.setScopes(Scope.parse(getScopes())); + // should really be a UI option, but was not previously + // server is mandated to support HS256 but if we do not specify things that it produces + // then they would never be checked. + // rather we just say "I support anything, and let the check for the specific ones fail and fall through + ArrayList allAlgorthms = new ArrayList<>(); + allAlgorthms.addAll(JWSAlgorithm.Family.HMAC_SHA); + allAlgorthms.addAll(JWSAlgorithm.Family.SIGNATURE); + providerMetadata.setIDTokenJWSAlgs(allAlgorthms); + return providerMetadata; + } catch (URISyntaxException e) { + throw new IllegalStateException("could not create provider metadata", e); + } + } + + private ClientAuthenticationMethod getClientAuthenticationMethod() { + if (tokenAuthMethod == TokenAuthMethod.client_secret_post) { + return ClientAuthenticationMethod.CLIENT_SECRET_POST; + } + return ClientAuthenticationMethod.CLIENT_SECRET_BASIC; + } + private static T validateNonNull(String fieldName, T value) throws FormException { if (value == null) { throw new FormException(fieldName + " is mandatory", fieldName); @@ -117,6 +176,20 @@ private static T validateNonNull(String fieldName, T value) throws FormExcep return value; } + /** + * Convert the given string to a URI or null if the string is null; + * @param uri a string representing a URI or {@code null} + * @return a new URI representing the provided string or null. + * @throws URISyntaxException if {@code uri} can not be converted to a {@link URI} + */ + @CheckForNull + private static URI toURIOrNull(String uri) throws URISyntaxException { + if (uri == null) { + return null; + } + return new URI(uri); + } + @Extension @Symbol("manual") public static class DescriptorImpl extends Descriptor { @@ -180,6 +253,15 @@ public FormValidation doCheckScopes(@QueryParameter String value) { return FormValidation.ok(); } + @POST + public FormValidation doCheckIssuer(@QueryParameter String issuer) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + if (Util.fixEmptyAndTrim(issuer) == null) { + return FormValidation.warning(Messages.OicSecurityRealm_IssuerRequired()); + } + return FormValidation.ok(); + } + @POST public FormValidation doCheckTokenServerUrl(@QueryParameter String value) { Jenkins.get().checkPermission(Jenkins.ADMINISTER); diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java b/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java index a8f29250..85192efd 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java @@ -1,12 +1,10 @@ package org.jenkinsci.plugins.oic; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpHeaders; -import com.google.api.client.http.HttpRequest; -import com.google.api.client.http.HttpResponseException; -import com.google.api.client.json.gson.GsonFactory; -import com.google.gson.JsonParseException; -import edu.umd.cs.findbugs.annotations.CheckForNull; +import com.nimbusds.jose.util.ResourceRetriever; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; import hudson.Extension; import hudson.RelativePath; import hudson.Util; @@ -15,21 +13,25 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; -import java.nio.charset.Charset; import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; import jenkins.model.Jenkins; -import org.apache.commons.lang.StringUtils; import org.jenkinsci.Symbol; -import org.jenkinsci.plugins.oic.OicSecurityRealm.TokenAuthMethod; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.verb.POST; +import org.pac4j.core.exception.TechnicalException; +import org.pac4j.oidc.config.OidcConfiguration; public class OicServerWellKnownConfiguration extends OicServerConfiguration { @@ -40,20 +42,13 @@ public class OicServerWellKnownConfiguration extends OicServerConfiguration { private final String wellKnownOpenIDConfigurationUrl; private String scopesOverride; - private transient String authorizationServerUrl; - private transient String tokenServerUrl; - private transient String jwksServerUrl; - private transient String endSessionUrl; - private transient String scopes; - private transient String userInfoServerUrl; - private transient boolean useRefreshTokens; - private transient TokenAuthMethod tokenAuthMethod; - /** * Time of the wellknown configuration expiration */ private transient LocalDateTime wellKnownExpires = null; + private transient volatile OIDCProviderMetadata oidcProviderMetadata; + @DataBoundConstructor public OicServerWellKnownConfiguration(String wellKnownOpenIDConfigurationUrl) { this.wellKnownOpenIDConfigurationUrl = Objects.requireNonNull(wellKnownOpenIDConfigurationUrl); @@ -64,42 +59,6 @@ public void setScopesOverride(String scopesOverride) { this.scopesOverride = Util.fixEmptyAndTrim(scopesOverride); } - @Override - public String getAuthorizationServerUrl() { - loadWellKnownConfigIfNeeded(); - return authorizationServerUrl; - } - - @Override - @CheckForNull - public String getEndSessionUrl() { - loadWellKnownConfigIfNeeded(); - return endSessionUrl; - } - - @Override - public String getJwksServerUrl() { - loadWellKnownConfigIfNeeded(); - return jwksServerUrl; - } - - /** - * Returns {@link #getScopesOverride()} if set, otherwise the scopes from the published metadata if set, otherwise "openid email". - */ - @Override - public String getScopes() { - loadWellKnownConfigIfNeeded(); - if (scopesOverride != null) { - return scopesOverride; - } - if (scopes != null) { - return scopes; - } - // server did not advertise anything and no overrides set. - // email may not be supported, but it is relatively common so try anyway - return "openid email"; - } - public String getScopesOverride() { return scopesOverride; } @@ -108,89 +67,89 @@ public String getWellKnownOpenIDConfigurationUrl() { return wellKnownOpenIDConfigurationUrl; } - @Override - public String getTokenServerUrl() { - loadWellKnownConfigIfNeeded(); - return tokenServerUrl; - } - - @Override - public String getUserInfoServerUrl() { - loadWellKnownConfigIfNeeded(); - return userInfoServerUrl; - } - - @Override - public boolean isUseRefreshTokens() { - loadWellKnownConfigIfNeeded(); - return useRefreshTokens; - } - - @Override - public TokenAuthMethod getTokenAuthMethod() { - loadWellKnownConfigIfNeeded(); - return tokenAuthMethod; + @Restricted(NoExternalUse.class) // for testing only + void invalidateProviderMetadata() { + // TODO XXX test code should be refactored to not make changes + oidcProviderMetadata = null; } /** * Obtain the provider configuration from the configured well known URL if it * has not yet been obtained or requires a refresh. */ - private void loadWellKnownConfigIfNeeded() { + @Override + public OIDCProviderMetadata toProviderMetadata() { + // we perform this download manually rather than letting pac4j perform it + // so that we can cache and expire the result. + // pac4j will cache the result yet never expire it. LocalDateTime now = LocalDateTime.now(); if (this.wellKnownExpires != null && this.wellKnownExpires.isBefore(now)) { // configuration is still fresh - return; + return oidcProviderMetadata; } - // Get the well-known configuration from the specified URL + // Download OIDC metadata + // we need to configure timeouts, headers as well as SSL (hostname verifier etc..) + // which may be disabled in the configuration + ResourceRetriever rr = ((OicSecurityRealm) (Jenkins.get().getSecurityRealm())).getResourceRetriever(); try { - URL url = new URL(wellKnownOpenIDConfigurationUrl); - OicSecurityRealm realm = (OicSecurityRealm) Jenkins.get().getSecurityRealm(); - HttpRequest request = - realm.getHttpTransport().createRequestFactory().buildGetRequest(new GenericUrl(url)); - - com.google.api.client.http.HttpResponse response = request.execute(); - WellKnownOpenIDConfigurationResponse config = GsonFactory.getDefaultInstance() - .fromInputStream( - response.getContent(), - Charset.defaultCharset(), - WellKnownOpenIDConfigurationResponse.class); - - this.authorizationServerUrl = config.getAuthorizationEndpoint(); - this.tokenServerUrl = config.getTokenEndpoint(); - this.jwksServerUrl = config.getJwksUri(); - this.tokenAuthMethod = config.getPreferredTokenAuthMethod(); - this.userInfoServerUrl = config.getUserinfoEndpoint(); - if (config.getScopesSupported() != null - && !config.getScopesSupported().isEmpty()) { - this.scopes = StringUtils.join(config.getScopesSupported(), " "); + OIDCProviderMetadata _oidcProviderMetadata = + OIDCProviderMetadata.parse(rr.retrieveResource(new URL(wellKnownOpenIDConfigurationUrl)) + .getContent()); + String _scopesOverride = getScopesOverride(); + if (_scopesOverride != null) { + // split the scopes by space + String[] splitScopes = _scopesOverride.split("\\s+"); + _oidcProviderMetadata.setScopes(new Scope(splitScopes)); } - this.endSessionUrl = config.getEndSessionEndpoint(); - - if (config.getGrantTypesSupported() != null) { - this.useRefreshTokens = config.getGrantTypesSupported().contains("refresh_token"); - } else { - this.useRefreshTokens = false; + // we do not expose enough to be able to configure all authentication methods, + // so limit supported auth methods to CLIENT_SECRET_BASIC / CLIENT_SECRET_POST + List tokenEndpointAuthMethods = + _oidcProviderMetadata.getTokenEndpointAuthMethods(); + if (tokenEndpointAuthMethods != null) { + List filteredEndpointAuthMethods = + new ArrayList<>(tokenEndpointAuthMethods); + filteredEndpointAuthMethods.removeIf(cam -> cam != ClientAuthenticationMethod.CLIENT_SECRET_BASIC + && cam != ClientAuthenticationMethod.CLIENT_SECRET_POST); + if (filteredEndpointAuthMethods.isEmpty()) { + LOGGER.log( + Level.WARNING, + "OIDC well-known configuration reports only unsupported token authentication methods (authentication may not work): " + + tokenEndpointAuthMethods.stream() + .map(Object::toString) + .collect(Collectors.joining(",", "[", "]"))); + _oidcProviderMetadata.setTokenEndpointAuthMethods(null); + } else { + _oidcProviderMetadata.setTokenEndpointAuthMethods(filteredEndpointAuthMethods); + } } - setWellKnownExpires(response.getHeaders()); + oidcProviderMetadata = _oidcProviderMetadata; + // TODO XXX need to obtain the expiry! + setWellKnownExpires(/*response.getHeaders()*/ ); + return oidcProviderMetadata; } catch (MalformedURLException e) { LOGGER.log(Level.SEVERE, "Invalid WellKnown OpenID Configuration URL", e); - } catch (HttpResponseException e) { - LOGGER.log(Level.SEVERE, "Could not get wellknown OpenID Configuration", e); - } catch (JsonParseException e) { + } catch (ParseException e) { LOGGER.log(Level.SEVERE, "Could not parse wellknown OpenID Configuration", e); } catch (IOException e) { LOGGER.log(Level.SEVERE, "Error while loading wellknown OpenID Configuration", e); } + if (oidcProviderMetadata != null) { + // return the previously downloaded but expired copy with the hope it still works. + // although if the well known url is down it is unlikely the rest of the provider is healthy, still. we can + // hope. + return oidcProviderMetadata; + } + throw new IllegalStateException("Well known configuration could not be loaded, login can not preceed."); } /** * Parse headers to determine expiration date */ - private void setWellKnownExpires(HttpHeaders headers) { - String expires = Util.fixEmptyAndTrim(headers.getExpires()); + // XXX TODO + private void setWellKnownExpires(/* HttpHeaders headers*/ ) { + String expires = "0"; // Util.fixEmptyAndTrim(headers.getExpires()); // expires 0 means no cache // we could (should?) have a look at Cache-Control header and max-age but for // simplicity @@ -226,35 +185,23 @@ public FormValidation doCheckWellKnownOpenIDConfigurationUrl( return FormValidation.error(Messages.OicSecurityRealm_NotAValidURL()); } try { - URL url = new URL(wellKnownOpenIDConfigurationUrl); - HttpRequest request = OicSecurityRealm.constructHttpTransport(disableSslVerification) - .createRequestFactory() - .buildGetRequest(new GenericUrl(url)); - com.google.api.client.http.HttpResponse response = request.execute(); + // TODO XXX handle disabling SSL Verification etc.. + OidcConfiguration configuration = new OidcConfiguration(); + configuration.setClientId("ignored-but-requred"); + configuration.setSecret("ignored-but-required"); + configuration.setDiscoveryURI(wellKnownOpenIDConfigurationUrl); + + OIDCProviderMetadata providerMetadata = configuration.findProviderMetadata(); - // Try to parse the response. If it's not valid, a JsonParseException will be - // thrown indicating - // that it's not a valid JSON describing an OpenID Connect endpoint - WellKnownOpenIDConfigurationResponse config = GsonFactory.getDefaultInstance() - .fromInputStream( - response.getContent(), - Charset.defaultCharset(), - WellKnownOpenIDConfigurationResponse.class); - if (config.getAuthorizationEndpoint() == null || config.getTokenEndpoint() == null) { + if (providerMetadata.getAuthorizationEndpointURI() == null + || providerMetadata.getTokenEndpointURI() == null) { return FormValidation.warning(Messages.OicSecurityRealm_URLNotAOpenIdEnpoint()); } - return FormValidation.ok(); - } catch (MalformedURLException e) { - return FormValidation.error(e, Messages.OicSecurityRealm_NotAValidURL()); - } catch (HttpResponseException e) { - return FormValidation.error( - e, - Messages.OicSecurityRealm_CouldNotRetreiveWellKnownConfig( - e.getStatusCode(), e.getStatusMessage())); - } catch (JsonParseException e) { - return FormValidation.error(e, Messages.OicSecurityRealm_CouldNotParseResponse()); - } catch (IOException e) { + } catch (TechnicalException e) { + if (e.getCause() instanceof ParseException) { + return FormValidation.error(e, Messages.OicSecurityRealm_URLNotAOpenIdEnpoint()); + } return FormValidation.error(e, Messages.OicSecurityRealm_ErrorRetreivingWellKnownConfig()); } } diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicSession.java b/src/main/java/org/jenkinsci/plugins/oic/OicSession.java deleted file mode 100644 index 66d71649..00000000 --- a/src/main/java/org/jenkinsci/plugins/oic/OicSession.java +++ /dev/null @@ -1,271 +0,0 @@ -/* - * The MIT License - * - * Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi - * - * 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.oauth2.AuthorizationCodeFlow; -import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl; -import com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl; -import com.google.api.client.auth.openidconnect.IdToken; -import com.google.api.client.util.Base64; -import com.google.common.annotations.VisibleForTesting; -import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import hudson.model.Failure; -import java.io.IOException; -import java.io.Serializable; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Map; -import java.util.UUID; -import javax.servlet.http.HttpSession; -import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.DoNotUse; -import org.kohsuke.stapler.HttpRedirect; -import org.kohsuke.stapler.HttpResponse; -import org.kohsuke.stapler.Stapler; -import org.kohsuke.stapler.StaplerRequest; -import org.springframework.web.util.UriComponentsBuilder; - -import static org.jenkinsci.plugins.oic.OicSecurityRealm.ensureStateAttribute; - -/** - * The state of the OpenId connect request. - * - * Verifies the validity of the response by comparing the state. - * - * @author Kohsuke Kawaguchi - initial author? - * @author Ryan Campbell - initial author? - * @author Michael Bischoff - adoptation - */ -@SuppressWarnings("deprecation") -abstract class OicSession implements Serializable { - private static final long serialVersionUID = 1L; - - /** - * An opaque value used by the client to maintain state between the request and callback. - */ - @VisibleForTesting - @NonNull - String state = Base64.encodeBase64URLSafeString(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)) - .substring(0, 20); - /** - * More random state, this time extending to the id token. - */ - @VisibleForTesting - String nonce; - /** - * The url the user was trying to navigate to. - */ - private final String from; - /** - * Where it will redirect to once the scopes are approved by the user. - */ - private final String redirectUrl; - /** - * PKCE Verifier code if activated - */ - String pkceVerifierCode; - - OicSession(String from, String redirectUrl) { - this.from = from; - this.redirectUrl = redirectUrl; - this.withNonceDisabled(false); - } - - /** - * Activate or disable Nonce - */ - public OicSession withNonceDisabled(boolean isDisabled) { - if (isDisabled) { - this.nonce = null; - } else { - if (this.nonce == null) { - this.nonce = UUID.randomUUID().toString(); - } - } - return this; - } - - /** - * Helper class to compute PKCE Challenge - */ - private static class PKCE { - /** Challenge code of verifier code */ - public String challengeCode; - /** Methode used for computing challenge code */ - public String challengeCodeMethod; - - public PKCE(String verifierCode) { - try { - byte[] bytes = verifierCode.getBytes(StandardCharsets.UTF_8); - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(bytes, 0, bytes.length); - byte[] digest = md.digest(); - challengeCode = Base64.encodeBase64URLSafeString(digest); - challengeCodeMethod = "S256"; - } catch (NoSuchAlgorithmException e) { - challengeCode = verifierCode; - challengeCodeMethod = "plain"; - } - } - - /** - * Generate base64 verifier code - */ - public static String generateVerifierCode() { - try { - SecureRandom random = SecureRandom.getInstanceStrong(); - byte[] code = new byte[32]; - random.nextBytes(code); - return Base64.encodeBase64URLSafeString(code); - } catch (NoSuchAlgorithmException e) { - return null; - } - } - } - - /** - * Activate or disable PKCE - */ - public OicSession withPkceEnabled(boolean isEnabled) { - if (isEnabled) { - this.pkceVerifierCode = PKCE.generateVerifierCode(); - } else { - this.pkceVerifierCode = null; - } - return this; - } - - /** - * Setup the session - isolate warning suppression - */ - @SuppressFBWarnings("J2EE_STORE_OF_NON_SERIALIZABLE_OBJECT_INTO_SESSION") - private void setupOicSession(HttpSession session) { - // remember this in the session - session.setAttribute(SESSION_NAME, this); - } - - /** - * Starts the login session. - * @return an {@link HttpResponse} - */ - public HttpResponse commenceLogin(AuthorizationCodeFlow flow) { - setupOicSession(Stapler.getCurrentRequest().getSession()); - AuthorizationCodeRequestUrl authorizationCodeRequestUrl = - flow.newAuthorizationUrl().setState(state).setRedirectUri(redirectUrl); - if (this.nonce != null) { - authorizationCodeRequestUrl.set("nonce", this.nonce); // no @Key field defined in AuthorizationRequestUrl - } - - if (this.pkceVerifierCode != null) { - PKCE pkce = new PKCE(this.pkceVerifierCode); - authorizationCodeRequestUrl.setCodeChallengeMethod(pkce.challengeCodeMethod); - authorizationCodeRequestUrl.setCodeChallenge(pkce.challengeCode); - } - return new HttpRedirect(authorizationCodeRequestUrl.toString()); - } - - /** - * When the identity provider is done with its thing, the user comes back here. - * @return an {@link HttpResponse} - */ - public HttpResponse finishLogin(StaplerRequest request, AuthorizationCodeFlow flow) throws IOException { - final String requestURL; - if (request.getQueryString() != null) { - StringBuffer buf = request.getRequestURL(); - buf.append('?').append(request.getQueryString()); - requestURL = buf.toString(); - } else { - // some providers ADFS! post data using a form rather than the queryString. - requestURL = convertFormToQueryParameters(request); - } - AuthorizationCodeResponseUrl responseUrl = new AuthorizationCodeResponseUrl(requestURL); - if (!state.equals(responseUrl.getState())) { - return new Failure("State is invalid"); - } - if (responseUrl.getError() != null) { - return new Failure("Error from provider: " + responseUrl.getError() + ". Details: " - + responseUrl.getErrorDescription()); - } - - String code = responseUrl.getCode(); - if (code == null) { - return new Failure("Missing authorization code"); - } - - HttpSession session = request.getSession(false); - if (session != null) { - // avoid session fixation - session.invalidate(); - } - ensureStateAttribute(request.getSession(true), getState()); - return onSuccess(code, flow); - } - - @VisibleForTesting - @Restricted(DoNotUse.class) - protected static String convertFormToQueryParameters(StaplerRequest request) { - Map parameterMap = request.getParameterMap(); - UriComponentsBuilder queryBuilder = - UriComponentsBuilder.fromHttpUrl(request.getRequestURL().toString()); - for (Map.Entry entry : parameterMap.entrySet()) { - queryBuilder.queryParam(entry.getKey(), (Object[]) entry.getValue()); - } - return queryBuilder.build().toUriString(); - } - /** - * Where was the user trying to navigate to when they had to login? - * - * @return the url the user wants to reach - */ - protected String getFrom() { - return from; - } - - @NonNull - public String getState() { - return this.state; - } - - protected abstract HttpResponse onSuccess(String authorizationCode, AuthorizationCodeFlow flow); - - protected final boolean validateNonce(IdToken idToken) { - if (idToken == null || this.nonce == null) { - // validation impossible or disabled - return true; - } - return this.nonce.equals(idToken.getPayload().getNonce()); - } - - /** - * Gets the {@link OicSession} associated with HTTP session in the current extend. - */ - public static OicSession getCurrent() { - return (OicSession) Stapler.getCurrentRequest().getSession().getAttribute(SESSION_NAME); - } - - private static final String SESSION_NAME = OicSession.class.getName(); -} diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicTokenResponse.java b/src/main/java/org/jenkinsci/plugins/oic/OicTokenResponse.java deleted file mode 100644 index ea381df4..00000000 --- a/src/main/java/org/jenkinsci/plugins/oic/OicTokenResponse.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * The MIT License - * - * Copyright (c) 2022 Michael Doubez - * - * 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.oauth2.TokenResponse; -import com.google.api.client.auth.openidconnect.IdToken; -import com.google.api.client.util.Key; -import hudson.Util; -import java.io.IOException; -import java.util.Objects; - -/** Custom TokenResponse with id_token capabilities - * - * Customisation includes: - * - expires_in: can be Long or String of Long - */ -public class OicTokenResponse extends TokenResponse { - - @Key("id_token") - private String idToken; - - public final String getIdToken() { - return this.idToken; - } - - public OicTokenResponse setIdToken(String str) { - this.idToken = (String) Util.fixNull(str); - return this; - } - - public IdToken parseIdToken() throws IOException { - if (this.idToken == null) { - return null; - } - return IdToken.parse(getFactory(), this.idToken); - } - - /** - * Lifetime in seconds of the access token (for example 3600 for an hour) or {@code null} for - * none. - */ - @Key("expires_in") - private Object expiresInSeconds; - - /** - * Returns the lifetime in seconds of the access token (for example 3600 for an hour) or - * {@code null} for none. - */ - @Override - public final Long getExpiresInSeconds() { - if (expiresInSeconds == null) { - return null; - } - return Long.class.isInstance(expiresInSeconds) - ? (Long) expiresInSeconds - : Long.valueOf(String.valueOf(expiresInSeconds)); - } - - /** - * Sets the lifetime in seconds of the access token (for example 3600 for an hour) or {@code null} - * for none. - * - *

- * Overriding is only supported for the purpose of calling the super implementation and changing - * the return type, but nothing else. - *

- */ - @Override - public OicTokenResponse setExpiresInSeconds(Long expiresInSeconds) { - this.expiresInSeconds = expiresInSeconds; - return this; - } - - /** clone */ - @Override - public OicTokenResponse clone() { - return (OicTokenResponse) super.clone(); - } - - /** - * Overriding equals() - */ - @Override - public boolean equals(Object o) { - if (o == this) { - return true; - } - if (o == null || !(o instanceof OicTokenResponse)) { - return false; - } - - OicTokenResponse oo = (OicTokenResponse) o; - - return super.equals(o) && Objects.equals(getExpiresInSeconds(), oo.getExpiresInSeconds()); - } - - /** - * Overriding hashCode() - */ - @Override - public int hashCode() { - return super.hashCode(); - } - - // ---- override com.google.api.client.auth.oauth2.TokenResponse - - @Override // com.google.api.client.auth.oauth2.TokenResponse, com.google.api.client.json.GenericJson, - // com.google.api.client.util.GenericData - public OicTokenResponse set(String str, Object obj) { - return (OicTokenResponse) super.set(str, obj); - } - - @Override // com.google.api.client.auth.oauth2.TokenResponse - public OicTokenResponse setAccessToken(String str) { - super.setAccessToken(str); - return this; - } - - @Override // com.google.api.client.auth.oauth2.TokenResponse - public OicTokenResponse setRefreshToken(String str) { - super.setRefreshToken(str); - return this; - } - - @Override // com.google.api.client.auth.oauth2.TokenResponse - public OicTokenResponse setScope(String str) { - super.setScope(str); - return this; - } - - @Override // com.google.api.client.auth.oauth2.TokenResponse - public OicTokenResponse setTokenType(String str) { - super.setTokenType(str); - return this; - } -} diff --git a/src/main/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetriever.java b/src/main/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetriever.java new file mode 100644 index 00000000..aa190dae --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetriever.java @@ -0,0 +1,76 @@ +package org.jenkinsci.plugins.oic; + +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jose.util.ResourceRetriever; +import hudson.ProxyConfiguration; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; +import javax.net.ssl.HttpsURLConnection; +import jenkins.security.FIPS140; +import jenkins.util.SystemProperties; +import org.jenkinsci.plugins.oic.ssl.IgnoringHostNameVerifier; +import org.jenkinsci.plugins.oic.ssl.TLSUtils; +import org.pac4j.core.context.HttpConstants; + +/** + * A {@link ResourceRetriever} that is configured with sane connection/timeout defaults and the Jenkins proxy. + */ +class ProxyAwareResourceRetriever extends DefaultResourceRetriever { + + @SuppressWarnings("boxing") + private static final int CONNECTION_TIMEOUT_MS = SystemProperties.getInteger("OIC_CONNECTION_TIMEOUT_MS", 2_000); + + @SuppressWarnings("boxing") + private static final int READ_TIMEOUT_MS = SystemProperties.getInteger("OIC_CONNECTION_READ_TIMEOUT_MS", 5_000); + + @SuppressWarnings("boxing") + private static final int SIZE_LIMIT = SystemProperties.getInteger("OIC_CONNECTION_SIZE_LIMIT", 0); + + private final boolean disableTLSValidation; + + private ProxyAwareResourceRetriever(boolean disableTLSValidation) + throws KeyManagementException, NoSuchAlgorithmException { + super( + CONNECTION_TIMEOUT_MS, + READ_TIMEOUT_MS, + SIZE_LIMIT, + true, + disableTLSValidation ? TLSUtils.createAnythingGoesSSLSocketFactory() : null); + this.disableTLSValidation = disableTLSValidation; + // set the same default headers as the in the default client should a resolver not be specified + // https://github.com/pac4j/pac4j/blob/pac4j-parent-5.7.7/pac4j-oidc/src/main/java/org/pac4j/oidc/config/OidcConfiguration.java#L179-L193 + setHeaders(Map.of(HttpConstants.ACCEPT_HEADER, List.of(HttpConstants.APPLICATION_JSON))); + } + + @Override + protected HttpURLConnection openHTTPConnection(URL url) throws IOException { + @SuppressWarnings("deprecation") + HttpURLConnection con = (HttpURLConnection) ProxyConfiguration.open(url); + if (disableTLSValidation && con instanceof HttpsURLConnection) { + ((HttpsURLConnection) con).setHostnameVerifier(IgnoringHostNameVerifier.INSTANCE); + } + return con; + } + + /** + * Create a ResourceRetriver that uses the Jenkins ProxyConfiguration. + * @param disableTLSValidation {@code true} if we want to trust all certificates + */ + static ProxyAwareResourceRetriever createProxyAwareResourceRetriver(boolean disableTLSValidation) { + if (FIPS140.useCompliantAlgorithms() && disableTLSValidation) { + throw new IllegalArgumentException("Can not disable TLS validation when running Jenkins in FIPS 140 mode"); + } + try { + return new ProxyAwareResourceRetriever(disableTLSValidation); + } catch (KeyManagementException | NoSuchAlgorithmException e) { + // we are not using a keystore so KeyManagementException should never be thrown + // "TLS" is mandated by the spec. + throw new IllegalStateException("Could not construct the ProxyAwareResourceRetriver", e); + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponse.java b/src/main/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponse.java deleted file mode 100644 index 4ad9fc54..00000000 --- a/src/main/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponse.java +++ /dev/null @@ -1,160 +0,0 @@ -package org.jenkinsci.plugins.oic; - -import com.google.api.client.json.GenericJson; -import com.google.api.client.util.Key; -import com.google.common.base.Objects; -import java.util.Map; -import java.util.Set; -import org.jenkinsci.plugins.oic.OicSecurityRealm.TokenAuthMethod; - -/** - * OpenID Connect Discovery JSON. - * https://openid.net/specs/openid-connect-discovery-1_0.html - * - * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata: - * "Additional OpenID Provider Metadata parameters MAY also be used. Some are defined by other specifications, such as OpenID Connect Session Management 1.0" - * http://openid.net/specs/openid-connect-session-1_0.html#OPMetadata - * - * @author Steve Arch - */ -public class WellKnownOpenIDConfigurationResponse extends GenericJson { - @Key("authorization_endpoint") - private String authorizationEndpoint; - - @Key("token_endpoint") - private String tokenEndpoint; - - @Key("token_endpoint_auth_methods_supported") - private Set tokenAuthMethods; - - @Key("userinfo_endpoint") - private String userinfoEndpoint; - - @Key("jwks_uri") - private String jwksUri; - - @Key("scopes_supported") - private Set scopesSupported; - - @Key("grant_types_supported") - private Set grantTypesSupported; - - @Key("end_session_endpoint") - private String endSessionEndpoint; - - public String getAuthorizationEndpoint() { - return authorizationEndpoint; - } - - public String getTokenEndpoint() { - return tokenEndpoint; - } - - public Set getTokenAuthMethods() { - return tokenAuthMethods; - } - - public TokenAuthMethod getPreferredTokenAuthMethod() { - if (tokenAuthMethods != null && !tokenAuthMethods.isEmpty()) { - // Prefer post since that is what the original plugin implementation used - if (tokenAuthMethods.contains("client_secret_post")) { - return TokenAuthMethod.client_secret_post; - // The RFC recommends basic, so that's our number two choice - } else if (tokenAuthMethods.contains("client_secret_basic")) { - return TokenAuthMethod.client_secret_basic; - // Default to post, again because that was the original implementation - } else { - return TokenAuthMethod.client_secret_post; - } - } else { - return TokenAuthMethod.client_secret_post; - } - } - - public String getUserinfoEndpoint() { - return userinfoEndpoint; - } - - public String getJwksUri() { - return jwksUri; - } - - public Set getScopesSupported() { - return scopesSupported; - } - - public Set getGrantTypesSupported() { - return grantTypesSupported; - } - - public String getEndSessionEndpoint() { - return endSessionEndpoint; - } - - /** - * Mimicks {@link GenericJson#getUnknownKeys()}, but returning the map of known keys - * @return a map key-values pairs defined in this class - */ - public Map getKnownKeys() { - Map clone = this.clone(); - for (String key : this.getUnknownKeys().keySet()) { - clone.remove(key); - } - return clone; - } - - /** - * Overriding equals() - */ - @Override - public boolean equals(Object o) { - if (o == this) { - return true; - } - if (o == null || !(o instanceof WellKnownOpenIDConfigurationResponse)) { - return false; - } - - WellKnownOpenIDConfigurationResponse obj = (WellKnownOpenIDConfigurationResponse) o; - - if (!Objects.equal(authorizationEndpoint, obj.getAuthorizationEndpoint())) { - return false; - } - if (!Objects.equal(tokenEndpoint, obj.getTokenEndpoint())) { - return false; - } - if (!Objects.equal(userinfoEndpoint, obj.getUserinfoEndpoint())) { - return false; - } - if (!Objects.equal(jwksUri, obj.getJwksUri())) { - return false; - } - if (!Objects.equal(scopesSupported, obj.getScopesSupported())) { - return false; - } - if (!Objects.equal(grantTypesSupported, obj.getGrantTypesSupported())) { - return false; - } - if (!Objects.equal(endSessionEndpoint, obj.getEndSessionEndpoint())) { - return false; - } - - return true; - } - - /** - * Overriding hashCode() - */ - @Override - public int hashCode() { - return (authorizationEndpoint - + tokenAuthMethods - + tokenEndpoint - + userinfoEndpoint - + jwksUri - + scopesSupported - + grantTypesSupported - + endSessionEndpoint) - .hashCode(); - } -} diff --git a/src/main/java/org/jenkinsci/plugins/oic/ssl/AnythingGoesTrustManager.java b/src/main/java/org/jenkinsci/plugins/oic/ssl/AnythingGoesTrustManager.java new file mode 100644 index 00000000..ee4899b3 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/oic/ssl/AnythingGoesTrustManager.java @@ -0,0 +1,34 @@ +package org.jenkinsci.plugins.oic.ssl; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import javax.net.ssl.X509TrustManager; +import jenkins.security.FIPS140; + +@SuppressFBWarnings(value = "WEAK_TRUST_MANAGER", justification = "Opt in by user") +final class AnythingGoesTrustManager implements X509TrustManager { + + static final X509TrustManager INSTANCE = new AnythingGoesTrustManager(); + + private AnythingGoesTrustManager() {} + + @Override + public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + if (FIPS140.useCompliantAlgorithms()) { + throw new CertificateException("can not bypass certificate checking in FIPS mode"); + } + } + + @Override + public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + if (FIPS140.useCompliantAlgorithms()) { + throw new CertificateException("can not bypass certificate checking in FIPS mode"); + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } +} diff --git a/src/main/java/org/jenkinsci/plugins/oic/ssl/IgnoringHostNameVerifier.java b/src/main/java/org/jenkinsci/plugins/oic/ssl/IgnoringHostNameVerifier.java new file mode 100644 index 00000000..40e8b8de --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/oic/ssl/IgnoringHostNameVerifier.java @@ -0,0 +1,25 @@ +package org.jenkinsci.plugins.oic.ssl; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; +import jenkins.security.FIPS140; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * {@link HostnameVerifier} that accepts any presented hostanme including incorrect ones. + */ +@Restricted(NoExternalUse.class) +public final class IgnoringHostNameVerifier implements HostnameVerifier { + + public static final HostnameVerifier INSTANCE = new IgnoringHostNameVerifier(); + + private IgnoringHostNameVerifier() {} + + @Override + public boolean verify(String hostname, SSLSession session) { + // hostnames must be validated in FIPS mode + // outside of FIPS mode anything goes + return !FIPS140.useCompliantAlgorithms(); + } +} diff --git a/src/main/java/org/jenkinsci/plugins/oic/ssl/TLSUtils.java b/src/main/java/org/jenkinsci/plugins/oic/ssl/TLSUtils.java new file mode 100644 index 00000000..cebd9b16 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/oic/ssl/TLSUtils.java @@ -0,0 +1,29 @@ +package org.jenkinsci.plugins.oic.ssl; + +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import jenkins.security.FIPS140; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Restricted(NoExternalUse.class) +public class TLSUtils { + + /** + * Construct an {@link SSLSocketFactory} that trust all certificates using "TLS". + */ + public static SSLSocketFactory createAnythingGoesSSLSocketFactory() + throws KeyManagementException, NoSuchAlgorithmException { + if (FIPS140.useCompliantAlgorithms()) { + throw new IllegalStateException( + "createAnythingGoesSSLSocketFactory is not supported when running in FIPS mode"); + } + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] {AnythingGoesTrustManager.INSTANCE}, new SecureRandom()); + return sslContext.getSocketFactory(); + } +} diff --git a/src/main/resources/org/jenkinsci/plugins/oic/Messages.properties b/src/main/resources/org/jenkinsci/plugins/oic/Messages.properties index 24beeeb0..be66ee0f 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/Messages.properties +++ b/src/main/resources/org/jenkinsci/plugins/oic/Messages.properties @@ -1,6 +1,7 @@ OicLogoutAction.OicLogout = Oic Logout OicSecurityRealm.DisplayName = Login with Openid Connect +OicSecurityRealm.CouldNotRefreshToken = Unable to refresh access token OicSecurityRealm.ClientIdRequired = Client id is required. OicSecurityRealm.ClientSecretRequired = Client secret is required. OicSecurityRealm.URLNotAOpenIdEnpoint = URL does not seem to describe OpenID Connect endpoints @@ -15,9 +16,11 @@ OicSecurityRealm.RUSureOpenIdNotInScope = Are you sure you don''t want to includ OicSecurityRealm.ScopesRequired = Scopes is required. OicSecurityRealm.EndSessionURLKeyRequired = End Session URL Key is required. OicSecurityRealm.InvalidFieldName = Invalid field name - must be a valid JMESPath expression. +OicSecurityRealm.IssuerRequired = Issuer is required. OicSecurityRealm.NoIdTokenInResponse = No idtoken was provided in response to token request. OicSecurityRealm.IdTokenParseError = Idtoken could not be parsed. OicSecurityRealm.UsernameNotFound = No field ''{0}'' was supplied in the UserInfo or the IdToken payload to be used as the username. OicSecurityRealm.TokenRequestFailure = Token request failed: {0}" +OicSecurityRealm.TokenRefreshFailure = Unable to refresh access token OicServerWellKnownConfiguration.DisplayName = Discovery via well-known endpoint OicServerManualConfiguration.DisplayName = Manual entry diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-clientSecret.html b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-clientSecret.html index da38dc6e..9a80f1d7 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-clientSecret.html +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-clientSecret.html @@ -1,4 +1,5 @@
- The Client secret used by jenkins to authenticate itself to the openid connect provider. - Special value "none" can be used to disable client secret (only with client_secret_post authentication). + The Subject Idendifier Type to use as part of the flow with the openid connect provider. + This must be supported by the IdP. + Valid values are public and pairwise.
diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-subjectType.html b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-subjectType.html new file mode 100644 index 00000000..da38dc6e --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-subjectType.html @@ -0,0 +1,4 @@ +
+ The Client secret used by jenkins to authenticate itself to the openid connect provider. + Special value "none" can be used to disable client secret (only with client_secret_post authentication). +
diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.jelly b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.jelly index 2a9aa9ea..bfde1fff 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.jelly @@ -1,6 +1,11 @@ + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.properties b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.properties index 56bbf500..857f29e1 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.properties +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.properties @@ -1,6 +1,7 @@ AuthorizationServerUrl=Authorization server url Basic=Basic EndSessionUrl=End session URL for OpenID Provider +Issuer=Issuer JwksServerUrl=Jwks server url Post=Post Scopes=Scopes diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer.html b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer.html new file mode 100644 index 00000000..08ca9dc6 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer.html @@ -0,0 +1,3 @@ +
+ Strongly recommended. The received ID Token's issuer must match the specified issuer. +
\ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer_fr.html b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer_fr.html new file mode 100644 index 00000000..e799eda8 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer_fr.html @@ -0,0 +1,3 @@ +
+ Fortement recommandé. Le Token ID reçu doit avoir l'émetteur indiqué. +
\ No newline at end of file diff --git a/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java b/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java index 304d9269..d5de7619 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/ConfigurationAsCodeTest.java @@ -43,9 +43,11 @@ public void testConfig() { assertTrue(realm instanceof OicSecurityRealm); OicSecurityRealm oicSecurityRealm = (OicSecurityRealm) realm; - assertEquals( - "http://localhost/authorize", - oicSecurityRealm.getServerConfiguration().getAuthorizationServerUrl()); + OicServerManualConfiguration serverConf = + (OicServerManualConfiguration) oicSecurityRealm.getServerConfiguration(); + + assertEquals("http://localhost/authorize", serverConf.getAuthorizationServerUrl()); + assertEquals("http://localhost/", serverConf.getIssuer()); assertEquals("clientId", oicSecurityRealm.getClientId()); assertEquals("clientSecret", Secret.toString(oicSecurityRealm.getClientSecret())); assertTrue(oicSecurityRealm.isDisableSslVerification()); @@ -59,18 +61,12 @@ public void testConfig() { assertEquals("fullNameFieldName", oicSecurityRealm.getFullNameFieldName()); assertEquals("groupsFieldName", oicSecurityRealm.getGroupsFieldName()); assertTrue(oicSecurityRealm.isLogoutFromOpenidProvider()); - assertEquals("scopes", oicSecurityRealm.getServerConfiguration().getScopes()); - assertEquals( - "http://localhost/token", - oicSecurityRealm.getServerConfiguration().getTokenServerUrl()); - assertEquals( - TokenAuthMethod.client_secret_post, - oicSecurityRealm.getServerConfiguration().getTokenAuthMethod()); + assertEquals("scopes", serverConf.getScopes()); + assertEquals("http://localhost/token", serverConf.getTokenServerUrl()); + assertEquals(TokenAuthMethod.client_secret_post, serverConf.getTokenAuthMethod()); assertEquals("userNameField", oicSecurityRealm.getUserNameField()); assertTrue(oicSecurityRealm.isRootURLFromRequest()); - assertEquals( - "http://localhost/jwks", - oicSecurityRealm.getServerConfiguration().getJwksServerUrl()); + assertEquals("http://localhost/jwks", serverConf.getJwksServerUrl()); assertFalse(oicSecurityRealm.isDisableTokenVerification()); } @@ -106,10 +102,11 @@ public void testMinimal() throws Exception { assertTrue(realm instanceof OicSecurityRealm); OicSecurityRealm oicSecurityRealm = (OicSecurityRealm) realm; + OicServerManualConfiguration serverConf = + (OicServerManualConfiguration) oicSecurityRealm.getServerConfiguration(); - assertEquals( - "http://localhost/authorize", - oicSecurityRealm.getServerConfiguration().getAuthorizationServerUrl()); + assertEquals("http://localhost/authorize", serverConf.getAuthorizationServerUrl()); + assertEquals("http://localhost/", serverConf.getIssuer()); assertEquals("clientId", oicSecurityRealm.getClientId()); assertEquals("clientSecret", Secret.toString(oicSecurityRealm.getClientSecret())); assertFalse(oicSecurityRealm.isDisableSslVerification()); @@ -117,28 +114,26 @@ public void testMinimal() throws Exception { assertFalse(oicSecurityRealm.isEscapeHatchEnabled()); assertNull(oicSecurityRealm.getFullNameFieldName()); assertNull(oicSecurityRealm.getGroupsFieldName()); - assertEquals("openid email", oicSecurityRealm.getServerConfiguration().getScopes()); - assertEquals( - "http://localhost/token", - oicSecurityRealm.getServerConfiguration().getTokenServerUrl()); - assertEquals( - TokenAuthMethod.client_secret_post, - oicSecurityRealm.getServerConfiguration().getTokenAuthMethod()); + assertEquals("openid email", serverConf.getScopes()); + assertEquals("http://localhost/token", serverConf.getTokenServerUrl()); + assertEquals(TokenAuthMethod.client_secret_post, serverConf.getTokenAuthMethod()); assertEquals("sub", oicSecurityRealm.getUserNameField()); assertTrue(oicSecurityRealm.isLogoutFromOpenidProvider()); assertFalse(oicSecurityRealm.isRootURLFromRequest()); - assertEquals(null, oicSecurityRealm.getServerConfiguration().getJwksServerUrl()); + assertEquals(null, serverConf.getJwksServerUrl()); assertFalse(oicSecurityRealm.isDisableTokenVerification()); } @Rule(order = 0) public final WellKnownMockRule wellKnownMockRule = new WellKnownMockRule( "MOCK_PORT", - "{\"authorization_endpoint\": \"http://localhost:%1$d/authorize\"," + "{\"issuer\": \"http://localhost:%1$d/\"," + + "\"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/jwks\"," - + "\"scopes_supported\": null," + + "\"scopes_supported\": [\"openid\",\"email\"]," + + "\"subject_types_supported\": [\"public\"]," + "\"end_session_endpoint\":\"http://localhost:%1$d/logout\"}"); @Test @@ -148,36 +143,26 @@ public void testMinimalWellKnown() throws Exception { assertThat(realm, instanceOf(OicSecurityRealm.class)); OicSecurityRealm oicSecurityRealm = (OicSecurityRealm) realm; + assertThat(oicSecurityRealm.getServerConfiguration(), instanceOf(OicServerWellKnownConfiguration.class)); + OicServerWellKnownConfiguration serverConf = + (OicServerWellKnownConfiguration) oicSecurityRealm.getServerConfiguration(); + String urlBase = String.format("http://localhost:%d", wellKnownMockRule.port()); - assertThat(oicSecurityRealm.getServerConfiguration(), instanceOf(OicServerWellKnownConfiguration.class)); - assertEquals( - urlBase + "/well.known", - ((OicServerWellKnownConfiguration) oicSecurityRealm.getServerConfiguration()) - .getWellKnownOpenIDConfigurationUrl()); - assertEquals( - urlBase + "/authorize", - oicSecurityRealm.getServerConfiguration().getAuthorizationServerUrl()); - assertEquals( - urlBase + "/token", oicSecurityRealm.getServerConfiguration().getTokenServerUrl()); - assertEquals( - urlBase + "/jwks", oicSecurityRealm.getServerConfiguration().getJwksServerUrl()); - assertEquals("clientId", oicSecurityRealm.getClientId()); - assertEquals("clientSecret", Secret.toString(oicSecurityRealm.getClientSecret())); assertFalse(oicSecurityRealm.isDisableSslVerification()); assertNull(oicSecurityRealm.getEmailFieldName()); assertFalse(oicSecurityRealm.isEscapeHatchEnabled()); assertNull(oicSecurityRealm.getFullNameFieldName()); assertNull(oicSecurityRealm.getGroupsFieldName()); - assertEquals("openid email", oicSecurityRealm.getServerConfiguration().getScopes()); - assertEquals( - urlBase + "/token", oicSecurityRealm.getServerConfiguration().getTokenServerUrl()); - assertEquals( - TokenAuthMethod.client_secret_post, - oicSecurityRealm.getServerConfiguration().getTokenAuthMethod()); + + assertEquals("clientId", oicSecurityRealm.getClientId()); + assertEquals("clientSecret", Secret.toString(oicSecurityRealm.getClientSecret())); + assertEquals("sub", oicSecurityRealm.getUserNameField()); assertTrue(oicSecurityRealm.isLogoutFromOpenidProvider()); assertFalse(oicSecurityRealm.isDisableTokenVerification()); + + assertEquals(urlBase + "/well.known", serverConf.getWellKnownOpenIDConfigurationUrl()); } /** 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/FieldTest.java b/src/test/java/org/jenkinsci/plugins/oic/FieldTest.java index e75e116d..2beb61ee 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/FieldTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/FieldTest.java @@ -2,8 +2,8 @@ import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import com.github.tomakehurst.wiremock.junit.WireMockRule; -import com.google.api.client.json.GenericJson; import java.util.HashMap; +import java.util.Map; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; @@ -24,7 +24,7 @@ public void testNestedLookup() throws Exception { HashMap user = new HashMap<>(); user.put("id", "100"); - GenericJson payload = new GenericJson(); + Map payload = new HashMap<>(); payload.put("email", "myemail@example.com"); payload.put("user", user); payload.put("none", null); @@ -44,7 +44,7 @@ public void testNormalLookupDueToDot() throws Exception { HashMap user = new HashMap<>(); user.put("id", "100"); - GenericJson payload = new GenericJson(); + Map payload = new HashMap<>(); payload.put("email", "myemail@example.com"); payload.put("user", user); payload.put("none", null); @@ -67,7 +67,7 @@ public void testFieldProcessing() throws Exception { user.put("name", "john"); user.put("surname", "dow"); - GenericJson payload = new GenericJson(); + Map payload = new HashMap<>(); payload.put("user", user); TestRealm realm = new TestRealm(wireMockRule); @@ -77,7 +77,7 @@ public void testFieldProcessing() throws Exception { @Test public void testInvalidFieldName() throws Exception { - GenericJson payload = new GenericJson(); + Map payload = new HashMap<>(); payload.put("user", "john"); TestRealm realm = new TestRealm(wireMockRule); diff --git a/src/test/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactoryTest.java b/src/test/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactoryTest.java deleted file mode 100644 index cc65805a..00000000 --- a/src/test/java/org/jenkinsci/plugins/oic/JenkinsAwareConnectionFactoryTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.jenkinsci.plugins.oic; - -import hudson.ProxyConfiguration; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import jenkins.model.Jenkins; -import org.junit.Rule; -import org.junit.Test; -import org.jvnet.hudson.test.JenkinsRule; - -import static org.junit.Assert.assertNotNull; - -public class JenkinsAwareConnectionFactoryTest { - - private JenkinsAwareConnectionFactory factory = new JenkinsAwareConnectionFactory(); - - @Rule - public JenkinsRule jenkinsRule = new JenkinsRule(); - - @Test - public void testOpenConnection_WithNullProxy() throws ClassCastException, IOException { - Jenkins.getInstance().proxy = null; - URL url = new URL("http://localhost"); - HttpURLConnection conn = factory.openConnection(url); - assertNotNull(conn); - } - - @Test - public void testOpenConnection_WithProxy() throws ClassCastException, IOException { - Jenkins.getInstance().proxy = new ProxyConfiguration("someHost", 8000); - URL url = new URL("http://localhost"); - HttpURLConnection conn = factory.openConnection(url); - assertNotNull(conn); - } -} diff --git a/src/test/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifierTest.java b/src/test/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifierTest.java deleted file mode 100644 index 442ec45b..00000000 --- a/src/test/java/org/jenkinsci/plugins/oic/OicJsonWebTokenVerifierTest.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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.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.JsonFactory; -import com.google.api.client.json.gson.GsonFactory; -import com.google.api.client.json.webtoken.JsonWebSignature; -import com.google.api.client.util.Base64; -import com.google.api.client.util.Clock; -import com.google.api.client.util.SecurityUtils; -import com.google.api.client.util.StringUtils; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import org.junit.Rule; -import org.junit.Test; - -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 org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class OicJsonWebTokenVerifierTest { - - @Rule - public WireMockRule wireMockRule = new WireMockRule(new WireMockConfiguration().dynamicPort(), true); - - KeyPair keyPair = createKeyPair(); - - @Test - public void testVanillaCaseShouldbeSuccessfulAndVerifySignature() throws Exception { - wireMockRule.resetAll(); - IdToken idtoken = createIdToken(keyPair.getPrivate(), new HashMap<>()); - OicJsonWebTokenVerifier verifier = new OicJsonWebTokenVerifier( - "http://localhost:" + wireMockRule.port() + "/jwks", new OicJsonWebTokenVerifier.Builder()); - assertTrue(verifier.isJwksServerUrlAvailable()); - - wireMockRule.stubFor(get(urlPathEqualTo("/jwks")) - .willReturn(aResponse() - .withHeader("Content-Type", "application/json") - .withBody("{\"keys\":[{" + encodePublicKey(keyPair) + ",\"alg\":\"RS256\"" - + ",\"use\":\"sig\",\"kid\":\"jwks_key_id\"" - + "}]}"))); - - assertTrue(verifier.verifyIdToken(idtoken)); - assertTrue(verifier.isJwksServerUrlAvailable()); - } - - @Test - public void tesNoJWKSURIShouldBeSuccessfulAndNeverVerifySignature() throws Exception { - IdToken idtoken = createIdToken(keyPair.getPrivate(), new HashMap<>()); - OicJsonWebTokenVerifier verifier = new OicJsonWebTokenVerifier(null, new OicJsonWebTokenVerifier.Builder()); - assertFalse(verifier.isJwksServerUrlAvailable()); - - assertTrue(verifier.verifyIdToken(idtoken)); - } - - @Test - public void testCannotGetJWKSURIShouldbeSuccessfulAndDisableSignature() throws Exception { - wireMockRule.resetAll(); - IdToken idtoken = createIdToken(keyPair.getPrivate(), new HashMap<>()); - OicJsonWebTokenVerifier verifier = new OicJsonWebTokenVerifier( - "http://localhost:" + wireMockRule.port() + "/jwks", new OicJsonWebTokenVerifier.Builder()); - assertTrue(verifier.isJwksServerUrlAvailable()); - - wireMockRule.stubFor(get(urlPathEqualTo("/jwks")).willReturn(aResponse().withStatus(404))); - - assertTrue(verifier.verifyIdToken(idtoken)); - assertFalse(verifier.isJwksServerUrlAvailable()); - } - - @Test - public void testMissingAlgShouldbeSuccessfulAndDisableSignature() throws Exception { - wireMockRule.resetAll(); - IdToken idtoken = createIdToken(keyPair.getPrivate(), new HashMap<>()); - OicJsonWebTokenVerifier verifier = new OicJsonWebTokenVerifier( - "http://localhost:" + wireMockRule.port() + "/jwks", new OicJsonWebTokenVerifier.Builder()); - assertTrue(verifier.isJwksServerUrlAvailable()); - - wireMockRule.stubFor(get(urlPathEqualTo("/jwks")) - .willReturn(aResponse() - .withHeader("Content-Type", "application/json") - .withBody("{\"keys\":[{" + encodePublicKey(keyPair) + ",\"use\":\"sig\",\"kid\":\"jwks_key_id\"" - + "}]}"))); - - assertTrue(verifier.verifyIdToken(idtoken)); - assertFalse(verifier.isJwksServerUrlAvailable()); - } - - @Test - public void testUnknownAlgShouldbeSuccessfulAndDisableSignature() throws Exception { - wireMockRule.resetAll(); - IdToken idtoken = createIdToken(keyPair.getPrivate(), new HashMap<>()); - OicJsonWebTokenVerifier verifier = new OicJsonWebTokenVerifier( - "http://localhost:" + wireMockRule.port() + "/jwks", new OicJsonWebTokenVerifier.Builder()); - assertTrue(verifier.isJwksServerUrlAvailable()); - - wireMockRule.stubFor(get(urlPathEqualTo("/jwks")) - .willReturn(aResponse() - .withHeader("Content-Type", "application/json") - .withBody("{\"keys\":[{" + encodePublicKey(keyPair) + ",\"alg\":\"RSA-OAEP\"" - + ",\"use\":\"sig\",\"kid\":\"jwks_key_id\"" - + "}]}"))); - - assertTrue(verifier.verifyIdToken(idtoken)); - assertFalse(verifier.isJwksServerUrlAvailable()); - } - - private static KeyPair createKeyPair() { - try { - KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); - keyGen.initialize(2048); - return keyGen.generateKeyPair(); - } catch (NoSuchAlgorithmException e) { - /* should not happen */ - } - return null; - } - - private IdToken createIdToken(PrivateKey privateKey, Map keyValues) throws Exception { - 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("sub") - .setAudience(Collections.singletonList("clientId")) - .setNonce("nonce"); - for (Map.Entry keyValue : keyValues.entrySet()) { - payload.set(keyValue.getKey(), keyValue.getValue()); - } - - JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); - String content = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header)) - + "." - + Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload)); - byte[] contentBytes = StringUtils.getBytesUtf8(content); - byte[] signature = - SecurityUtils.sign(SecurityUtils.getSha256WithRsaSignatureAlgorithm(), privateKey, contentBytes); - return new IdToken(header, payload, signature, contentBytes); - } - - /** 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()) - + "\",\"kty\":\"RSA\""; - } -} diff --git a/src/test/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfigurationTest.java b/src/test/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfigurationTest.java index 0a27fb31..a662e9e2 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfigurationTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfigurationTest.java @@ -50,10 +50,18 @@ public void doCheckWellKnownOpenIDConfigurationUrl() throws IOException { descriptor.doCheckWellKnownOpenIDConfigurationUrl( jenkinsRule.jenkins.getRootUrl() + "/api/json", false), allOf( - hasKind(FormValidation.Kind.WARNING), - withMessage("URL does not seem to describe OpenID Connect endpoints"))); + hasKind(FormValidation.Kind.ERROR), + withMessageContaining("URL does not seem to describe OpenID Connect endpoints"))); + assertThat( descriptor.doCheckWellKnownOpenIDConfigurationUrl(jenkinsRule.jenkins.getRootUrl() + "/api/xml", false), + allOf( + hasKind(FormValidation.Kind.ERROR), + withMessageContaining("URL does not seem to describe OpenID Connect endpoints"))); + + assertThat( + descriptor.doCheckWellKnownOpenIDConfigurationUrl( + jenkinsRule.jenkins.getRootUrl() + "/does/not/exist", false), allOf( hasKind(FormValidation.Kind.ERROR), withMessageContaining("Error when retrieving well-known config"))); @@ -79,6 +87,7 @@ private void configureWireMockWellKnownEndpoint() { String authUrl = "http://localhost:" + wireMockRule.port() + "/authorization"; String tokenUrl = "http://localhost:" + wireMockRule.port() + "/token"; String userInfoUrl = "http://localhost:" + wireMockRule.port() + "/userinfo"; + String issuer = "http://localhost:" + wireMockRule.port() + "/"; String jwksUrl = "null"; String endSessionUrl = "null"; @@ -86,10 +95,11 @@ private void configureWireMockWellKnownEndpoint() { .willReturn(aResponse() .withHeader("Content-Type", "text/html; charset=utf-8") .withBody(String.format( - "{\"authorization_endpoint\": \"%s\", \"token_endpoint\":\"%s\", " + "{\"authorization_endpoint\": \"%s\", \"issuer\" :\"%s\", \"token_endpoint\":\"%s\", " + "\"userinfo_endpoint\":\"%s\",\"jwks_uri\":\"%s\", \"scopes_supported\": null, " + + "\"subject_types_supported\": [ \"public\" ], " + "\"end_session_endpoint\":\"%s\"}", - authUrl, tokenUrl, userInfoUrl, jwksUrl, endSessionUrl)))); + authUrl, issuer, tokenUrl, userInfoUrl, jwksUrl, endSessionUrl)))); } private static DescriptorImpl getDescriptor() { diff --git a/src/test/java/org/jenkinsci/plugins/oic/OicSessionTest.java b/src/test/java/org/jenkinsci/plugins/oic/OicSessionTest.java deleted file mode 100644 index 21c91ca8..00000000 --- a/src/test/java/org/jenkinsci/plugins/oic/OicSessionTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.jenkinsci.plugins.oic; - -import com.google.api.client.auth.oauth2.AuthorizationCodeFlow; -import java.io.IOException; -import java.util.SortedMap; -import java.util.TreeMap; -import jenkins.model.Jenkins; -import org.junit.Rule; -import org.junit.Test; -import org.jvnet.hudson.test.JenkinsRule; -import org.jvnet.hudson.test.WithoutJenkins; -import org.kohsuke.stapler.HttpResponse; -import org.kohsuke.stapler.StaplerRequest; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class OicSessionTest { - - @Rule - public JenkinsRule jenkinsRule = new JenkinsRule(); - - private OicSession session; - - private static final String from = "fromAddy"; - - public void init() throws IOException { - TestRealm realm = new TestRealm.Builder("http://localhost/") - .WithMinimalDefaults().WithScopes("openid").build(); - - session = new OicSession(from, buildOAuthRedirectUrl()) { - @Override - public HttpResponse onSuccess(String authorizationCode, AuthorizationCodeFlow flow) { - return null; - } - }; - } - - private String buildOAuthRedirectUrl() throws NullPointerException { - String rootUrl = Jenkins.get().getRootUrl(); - if (rootUrl == null) { - throw new NullPointerException("Jenkins root url should not be null"); - } else { - return rootUrl + "securityRealm/finishLogin"; - } - } - - @Test - public void getFrom() throws Exception { - init(); - assertEquals(from, session.getFrom()); - } - - @Test - public void getState() throws Exception { - init(); - assertNotEquals("", session.getState()); - } - - @Test - @WithoutJenkins - public void testFormToQueryParameters() { - StaplerRequest sr = mock(StaplerRequest.class); - when(sr.getRequestURL()) - .thenReturn(new StringBuffer("http://domain.invalid/jenkins/securityRealm/finishLogin")); - SortedMap parametersMap = new TreeMap<>(); - parametersMap.put("param1", new String[] {"p1k1"}); - parametersMap.put("param2", new String[] {"p2k1", "p2k2"}); - when(sr.getParameterMap()).thenReturn(parametersMap); - String converted = OicSession.convertFormToQueryParameters(sr); - assertEquals( - "http://domain.invalid/jenkins/securityRealm/finishLogin?param1=p1k1¶m2=p2k1¶m2=p2k2", - converted); - } -} diff --git a/src/test/java/org/jenkinsci/plugins/oic/OicTokenResponseTest.java b/src/test/java/org/jenkinsci/plugins/oic/OicTokenResponseTest.java deleted file mode 100644 index cc8b7bdb..00000000 --- a/src/test/java/org/jenkinsci/plugins/oic/OicTokenResponseTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.jenkinsci.plugins.oic; - -import com.google.api.client.json.gson.GsonFactory; -import java.io.IOException; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * We'd like to be more permissive by allowing: - * - both long literals and Strigns containing to accepted - */ -public class OicTokenResponseTest { - - private static final String JSON_WITH_LONG_AS_STRING = "{\"access_token\":\"2YotnFZFEjr1zCsicMWpAA\"," - + "\"token_type\":\"example\",\"expires_in\":\"3600\"," - + "\"refresh_token\":\"tGzv3JOkF0XG5Qx2TlKWIA\"," - + "\"example_parameter\":\"example_value\"}"; - - private static final String JSON_WITH_LONG_LITERAL = "{\"access_token\":\"2YotnFZFEjr1zCsicMWpAA\"," - + "\"token_type\":\"example\",\"expires_in\":3600," - + "\"refresh_token\":\"tGzv3JOkF0XG5Qx2TlKWIA\"," - + "\"example_parameter\":\"example_value\"}"; - - private static final String JSON_WITH_ABSENT = "{\"access_token\":\"2YotnFZFEjr1zCsicMWpAA\"," - + "\"token_type\":\"example\"," - + "\"refresh_token\":\"tGzv3JOkF0XG5Qx2TlKWIA\"," - + "\"example_parameter\":\"example_value\"}"; - - @Test - public void parseLongLiteral() throws IOException { - OicTokenResponse response = - GsonFactory.getDefaultInstance().fromString(JSON_WITH_LONG_LITERAL, OicTokenResponse.class); - assertEquals("2YotnFZFEjr1zCsicMWpAA", response.getAccessToken()); - assertEquals("example", response.getTokenType()); - assertEquals(3600L, response.getExpiresInSeconds().longValue()); - assertEquals("tGzv3JOkF0XG5Qx2TlKWIA", response.getRefreshToken()); - assertEquals("example_value", response.get("example_parameter")); - } - - @Test - public void parseStringWithLong() throws IOException { - OicTokenResponse response = - GsonFactory.getDefaultInstance().fromString(JSON_WITH_LONG_AS_STRING, OicTokenResponse.class); - assertEquals("2YotnFZFEjr1zCsicMWpAA", response.getAccessToken()); - assertEquals("example", response.getTokenType()); - assertEquals(3600L, response.getExpiresInSeconds().longValue()); - assertEquals("tGzv3JOkF0XG5Qx2TlKWIA", response.getRefreshToken()); - assertEquals("example_value", response.get("example_parameter")); - } - - @Test - public void testSetters() throws IOException { - OicTokenResponse response = new OicTokenResponse(); - assertEquals(response, response.setAccessToken("2YotnFZFEjr1zCsicMWpAA")); - assertEquals(response, response.setTokenType("example")); - assertEquals(response, response.setExpiresInSeconds(3600L)); - assertEquals(response, response.setRefreshToken("tGzv3JOkF0XG5Qx2TlKWIA")); - assertEquals(response, response.set("example_parameter", "example_value")); - assertEquals(response, response.setScope("myScope")); - assertEquals("2YotnFZFEjr1zCsicMWpAA", response.getAccessToken()); - assertEquals("example", response.getTokenType()); - assertEquals(3600L, response.getExpiresInSeconds().longValue()); - assertEquals("tGzv3JOkF0XG5Qx2TlKWIA", response.getRefreshToken()); - assertEquals("example_value", response.get("example_parameter")); - assertEquals("myScope", response.getScope()); - - OicTokenResponse cloned = response.clone(); - assertEquals(response.getAccessToken(), cloned.getAccessToken()); - assertEquals(response.getTokenType(), cloned.getTokenType()); - assertEquals( - response.getExpiresInSeconds().longValue(), - cloned.getExpiresInSeconds().longValue()); - assertEquals(response.getRefreshToken(), cloned.getRefreshToken()); - assertEquals(response.get("example_parameter"), cloned.get("example_parameter")); - assertEquals(response.getScope(), cloned.getScope()); - - assertTrue(response.equals(cloned)); - assertTrue(response.hashCode() == cloned.hashCode()); - } - - @Test - public void parseAbsent() throws IOException { - OicTokenResponse response = - GsonFactory.getDefaultInstance().fromString(JSON_WITH_ABSENT, OicTokenResponse.class); - assertEquals("2YotnFZFEjr1zCsicMWpAA", response.getAccessToken()); - assertEquals("example", response.getTokenType()); - assertEquals(null, response.getExpiresInSeconds()); - assertEquals("tGzv3JOkF0XG5Qx2TlKWIA", response.getRefreshToken()); - assertEquals("example_value", response.get("example_parameter")); - } -} diff --git a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java index 35048967..7fa2a582 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/PluginTest.java @@ -1,15 +1,17 @@ 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.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.api.client.util.Clock; 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; @@ -28,6 +30,7 @@ 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; @@ -47,6 +50,7 @@ import org.junit.Ignore; 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 org.kohsuke.stapler.Stapler; @@ -82,9 +86,10 @@ 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 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. + * goes through a login scenario, the openid provider is mocked and always + * returns state. We aren't checking if 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 PluginTest { @@ -97,7 +102,11 @@ public class PluginTest { List.of(Map.of("id", "id1", "name", "group1"), Map.of("id", "id2", "name", "group2")); @Rule - public WireMockRule wireMockRule = new WireMockRule(new WireMockConfiguration().dynamicPort(), true); + public WireMockRule wireMockRule = new WireMockRule( + new WireMockConfiguration() + .dynamicPort() + .notifier(new ConsoleNotifier(new DisableOnDebug(null).isDebugging())), + true); @Rule public JenkinsRule jenkinsRule = new JenkinsRule(); @@ -109,6 +118,9 @@ public class PluginTest { public void setUp() { jenkins = jenkinsRule.getInstance(); webClient = jenkinsRule.createWebClient(); + if (new DisableOnDebug(null).isDebugging()) { + webClient.getOptions().setTimeout(0); + } } @Test @@ -128,8 +140,7 @@ public void testLoginWithDefaults() throws Exception { verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(notMatching(".*&scope=.*"))); webClient.executeOnServer(() -> { HttpSession session = Stapler.getCurrentRequest().getSession(); - assertNull(OicSession.getCurrent()); - assertNotNull(OicSecurityRealm.getStateAttribute(session)); + assertNotNull(((OicSecurityRealm) Jenkins.get().getSecurityRealm()).getStateAttribute(session)); return null; }); } @@ -166,13 +177,17 @@ private void assertAnonymous() { 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=state&code=code"))); + "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(); @@ -197,13 +212,13 @@ public void testLoginWithPkceEnabled() throws Exception { verify(postRequestedFor(urlPathEqualTo("/token")).withRequestBody(matching(".*&code_verifier=[^&]+.*"))); // check PKCE - // - get codeChallenge + // - get codeChallenge final String codeChallenge = findAll(getRequestedFor(urlPathEqualTo("/authorization"))) .get(0) .queryParameter("code_challenge") .values() .get(0); - // - get verifierCode + // - get verifierCode Matcher m = Pattern.compile(".*&code_verifier=([^&]+).*") .matcher(findAll(postRequestedFor(urlPathEqualTo("/token"))) .get(0) @@ -211,7 +226,7 @@ public void testLoginWithPkceEnabled() throws Exception { assertTrue(m.find()); final String codeVerifier = m.group(1); - // - hash verifierCode + // - hash verifierCode byte[] bytes = codeVerifier.getBytes(); MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(bytes, 0, bytes.length); @@ -306,18 +321,25 @@ public void testConfigurationWithAutoConfiguration_withScopeOverride() throws Ex jenkins.setSecurityRealm(oicsr); assertEquals( "All scopes of WellKnown should be used", - "openid profile scope1 scope2 scope3", - oicsr.getServerConfiguration().getScopes()); + new Scope("openid", "profile", "scope1", "scope2", "scope3"), + oicsr.getServerConfiguration().toProviderMetadata().getScopes()); OicServerWellKnownConfiguration serverConfig = (OicServerWellKnownConfiguration) oicsr.getServerConfiguration(); serverConfig.setScopesOverride("openid profile scope2 other"); - assertEquals("scopes should be completely overridden", "openid profile scope2 other", serverConfig.getScopes()); + serverConfig.invalidateProviderMetadata(); // XXX should not be used as it is not a normal code flow, rather the + // code should create a new ServerConfig + assertEquals( + "scopes should be completely overridden", + new Scope("openid", "profile", "scope2", "other"), + serverConfig.toProviderMetadata().getScopes()); + serverConfig.invalidateProviderMetadata(); // XXX should not be used as it is not a normal code flow, rather the + // code should create a new ServerConfig serverConfig.setScopesOverride(""); assertEquals( "All scopes of WellKnown should be used", - "openid profile scope1 scope2 scope3", - oicsr.getServerConfiguration().getScopes()); + new Scope("openid", "profile", "scope1", "scope2", "scope3"), + serverConfig.toProviderMetadata().getScopes()); } @Test @@ -328,7 +350,10 @@ public void testConfigurationWithAutoConfiguration_withRefreshToken() throws Exc jenkins.setSecurityRealm(oicsr); assertTrue( "Refresh token should be enabled", - oicsr.getServerConfiguration().isUseRefreshTokens()); + oicsr.getServerConfiguration() + .toProviderMetadata() + .getGrantTypes() + .contains(GrantType.REFRESH_TOKEN)); } @Test @@ -521,7 +546,6 @@ private void expire() throws Exception { 60L, 1L, 60L)); - return null; }); } @@ -598,6 +622,7 @@ public void testLoginWithJWTSignature() throws Exception { jenkins.setSecurityRealm(new TestRealm.Builder(wireMockRule) .WithUserInfoServerUrl("http://localhost:" + wireMockRule.port() + "/userinfo") .WithJwksServerUrl("http://localhost:" + wireMockRule.port() + "/jwks") + .WithDisableTokenValidation(false) .build()); assertAnonymous(); @@ -839,24 +864,38 @@ 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(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", - JsonNull.INSTANCE, - "scopes_supported", - scopesSupported == null ? JsonNull.INSTANCE : scopesSupported, - "end_session_endpoint", - endSessionUrl == null ? JsonNull.INSTANCE : endSessionUrl, - "grant_types_supported", - grantTypesSupported))))); + .withBody(toJson(values)))); } @Test @@ -914,13 +953,13 @@ private KeyPair createKeyPair() throws NoSuchAlgorithmException { private String createIdToken(PrivateKey privateKey, Map keyValues) throws Exception { JsonWebSignature.Header header = new JsonWebSignature.Header().setAlgorithm("RS256").setKeyId("jwks_key_id"); - long now = Clock.SYSTEM.currentTimeMillis() / 1000; + long now = Clock.systemUTC().millis() / 1000; IdToken.Payload payload = new IdToken.Payload() .setExpirationTimeSeconds(now + 60L) .setIssuedAtTimeSeconds(now) - .setIssuer("issuer") + .setIssuer(TestRealm.ISSUER) .setSubject(TEST_USER_USERNAME) - .setAudience(Collections.singletonList("clientId")) + .setAudience(Collections.singletonList(TestRealm.CLIENT_ID)) .setNonce("nonce"); for (Map.Entry keyValue : keyValues.entrySet()) { payload.set(keyValue.getKey(), keyValue.getValue()); @@ -963,7 +1002,7 @@ public void testLoginWithUnreadableIdTokenShouldBeRefused() throws Exception { mockTokenReturnsIdToken("This is not an IdToken"); jenkins.setSecurityRealm(new TestRealm(wireMockRule, null, null, null)); assertAnonymous(); - webClient.assertFails(jenkins.getSecurityRealm().getLoginUrl(), 403); + webClient.assertFails(jenkins.getSecurityRealm().getLoginUrl(), 500); } @Test @@ -1098,9 +1137,7 @@ private Authentication getAuthentication() { private static @NonNull Map setUpKeyValuesNested() { return Map.of( "nested", - Map.of( - EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS, - GROUPS_FIELD, TEST_USER_GROUPS), + Map.of(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS, GROUPS_FIELD, TEST_USER_GROUPS), FULL_NAME_FIELD, TEST_USER_FULL_NAME); } @@ -1178,7 +1215,7 @@ private void mockTokenReturnsIdToken( @CheckForNull String idToken, @CheckForNull Consumer>... tokenAcceptors) { var token = new HashMap(); token.put("access_token", "AcCeSs_ToKeN"); - token.put("token_type", "example"); + token.put("token_type", "Bearer"); token.put("expires_in", "3600"); token.put("refresh_token", "ReFrEsH_ToKeN"); token.put("example_parameter", "example_value"); diff --git a/src/test/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetrieverTest.java b/src/test/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetrieverTest.java new file mode 100644 index 00000000..62354a08 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/oic/ProxyAwareResourceRetrieverTest.java @@ -0,0 +1,47 @@ +package org.jenkinsci.plugins.oic; + +import hudson.ProxyConfiguration; +import java.net.HttpURLConnection; +import java.net.UnknownHostException; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProxyAwareResourceRetrieverTest { + + @Rule + public JenkinsRule jr = new JenkinsRule(); + + @Test + public void testOpenConnection_WithoutProxy() throws Exception { + jr.jenkins.setProxy(null); + + ProxyAwareResourceRetriever retreiver = ProxyAwareResourceRetriever.createProxyAwareResourceRetriver(false); + HttpURLConnection conn = retreiver.openHTTPConnection(jr.getURL()); + assertNotNull(conn.getContent()); + } + + @Test + public void testOpenConnection_WithProxy() throws Exception { + jr.jenkins.setProxy(new ProxyConfiguration("ignored.invalid", 8000)); + + ProxyAwareResourceRetriever retreiver = ProxyAwareResourceRetriever.createProxyAwareResourceRetriver(false); + HttpURLConnection conn = retreiver.openHTTPConnection(jr.getURL()); + // should attempt to connect to the proxy which is ignored.invalid which can not be resolved and hence throw an + // UnknownHostException + assertThrows(UnknownHostException.class, () -> conn.getContent()); + } + + @Test + public void testOpenConnection_WithProxyAndExclusion() throws Exception { + jr.jenkins.setProxy(new ProxyConfiguration( + "ignored.invalid", 8000, null, null, jr.getURL().getHost())); + + ProxyAwareResourceRetriever retreiver = ProxyAwareResourceRetriever.createProxyAwareResourceRetriver(false); + HttpURLConnection conn = retreiver.openHTTPConnection(jr.getURL()); + assertNotNull(conn.getContent()); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java b/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java index d7f3cc5a..a36d434f 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java +++ b/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java @@ -7,8 +7,15 @@ import io.burt.jmespath.Expression; import java.io.IOException; import java.io.ObjectStreamException; -import org.kohsuke.stapler.HttpResponse; +import java.net.URISyntaxException; +import java.text.ParseException; import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.jee.context.JEEContextFactory; +import org.pac4j.jee.context.session.JEESessionStoreFactory; +import org.pac4j.oidc.client.OidcClient; public class TestRealm extends OicSecurityRealm { @@ -16,10 +23,12 @@ public class TestRealm extends OicSecurityRealm { public static final String EMAIL_FIELD = "email"; public static final String FULL_NAME_FIELD = "fullName"; public static final String GROUPS_FIELD = "groups"; + public static final String ISSUER = "https://localhost/"; public static class Builder { public String clientId = CLIENT_ID; public Secret clientSecret = Secret.fromString("secret"); + public String issuer = ISSUER; public String wellKnownOpenIDConfigurationUrl; public String tokenServerUrl; public String jwksServerUrl = null; @@ -42,6 +51,7 @@ public static class Builder { public Secret escapeHatchSecret = null; public String escapeHatchGroup = null; public boolean automanualconfigure = false; + public boolean disableTokenValidation = true; // opt in for some specific tests public Builder(WireMockRule wireMockRule) throws IOException { this("http://localhost:" + wireMockRule.port() + "/"); @@ -59,6 +69,11 @@ public Builder WithClient(String clientId, String clientSecret) { return this; } + public Builder WithIssuer(String issuer) { + this.issuer = issuer; + return this; + } + public Builder WithUserInfoServerUrl(String userInfoServerUrl) { this.userInfoServerUrl = userInfoServerUrl; return this; @@ -116,6 +131,11 @@ public Builder WithEscapeHatch( return this; } + public Builder WithDisableTokenValidation(boolean disableTokenValidation) { + this.disableTokenValidation = disableTokenValidation; + return this; + } + public TestRealm build() throws IOException { return new TestRealm(this); } @@ -131,7 +151,7 @@ public OicServerConfiguration buildServerConfiguration() { return conf; } OicServerManualConfiguration conf = - new OicServerManualConfiguration(tokenServerUrl, authorizationServerUrl); + new OicServerManualConfiguration(issuer, tokenServerUrl, authorizationServerUrl); conf.setTokenAuthMethod(tokenAuthMethod); conf.setUserInfoServerUrl(userInfoServerUrl); if (scopes != null) { @@ -164,6 +184,11 @@ public TestRealm(Builder builder) throws IOException { this.setEscapeHatchUsername(builder.escapeHatchUsername); this.setEscapeHatchSecret(builder.escapeHatchSecret); this.setEscapeHatchGroup(builder.escapeHatchGroup); + this.setDisableTokenVerification(builder.disableTokenValidation); + // 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. + super.createProxyAwareResourceRetriver(); } public TestRealm(WireMockRule wireMockRule, String userInfoServerUrl, String emailFieldName, String groupsFieldName) @@ -223,12 +248,19 @@ public Descriptor getDescriptor() { } @Override - public HttpResponse doFinishLogin(StaplerRequest request) throws IOException { - OicSession.getCurrent().state = "state"; + public void doFinishLogin(StaplerRequest request, StaplerResponse response) + throws IOException, ParseException, URISyntaxException { + /* + * PluginTest uses a hardCoded nonce "nonce" + */ if (!isNonceDisabled()) { - OicSession.getCurrent().nonce = "nonce"; + // only hack the nonce if the nonce is enabled + WebContext webContext = JEEContextFactory.INSTANCE.newContext(request, response); + SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore(); + OidcClient oidcClient = buildOidcClient(); + sessionStore.set(webContext, oidcClient.getNonceSessionAttributeName(), "nonce"); } - return super.doFinishLogin(request); + super.doFinishLogin(request, response); } public String getStringFieldFromJMESPath(Object object, String jmespathField) { diff --git a/src/test/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponseTest.java b/src/test/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponseTest.java deleted file mode 100644 index 525e5286..00000000 --- a/src/test/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponseTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package org.jenkinsci.plugins.oic; - -import com.google.api.client.json.gson.GsonFactory; -import java.io.IOException; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import org.junit.Test; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; - -public class WellKnownOpenIDConfigurationResponseTest { - - private static final String JSON_FROM_GOOGLE = "{" - + " \"issuer\": \"https://accounts.google.com\"," - + " \"authorization_endpoint\": \"https://accounts.google.com/o/oauth2/v2/auth\"," - + " \"token_endpoint\": \"https://www.googleapis.com/oauth2/v4/token\"," - + " \"userinfo_endpoint\": \"https://www.googleapis.com/oauth2/v3/userinfo\"," - + " \"revocation_endpoint\": \"https://accounts.google.com/o/oauth2/revoke\"," - + " \"jwks_uri\": \"https://www.googleapis.com/oauth2/v3/certs\"," - + " \"response_types_supported\": [" - + " \"code\"," - + " \"token\"," - + " \"id_token\"," - + " \"code token\"," - + " \"code id_token\"," - + " \"token id_token\"," - + " \"code token id_token\"," - + " \"none\"" - + " ]," - + " \"subject_types_supported\": [" - + " \"public\"" - + " ]," - + " \"id_token_signing_alg_values_supported\": [" - + " \"RS256\"" - + " ]," - + " \"scopes_supported\": [" - + " \"openid\"," - + " \"email\"," - + " \"profile\"" - + " ]," - + " \"token_endpoint_auth_methods_supported\": [" - + " \"client_secret_post\"," - + " \"client_secret_basic\"" - + " ]," - + " \"claims_supported\": [" - + " \"aud\"," - + " \"email\"," - + " \"email_verified\"," - + " \"exp\"," - + " \"family_name\"," - + " \"given_name\"," - + " \"iat\"," - + " \"iss\"," - + " \"locale\"," - + " \"name\"," - + " \"picture\"," - + " \"sub\"" - + " ]," - + " \"code_challenge_methods_supported\": [" - + " \"plain\"," - + " \"S256\"" - + " ]," - + " \"grant_types_supported\": [" - + " \"authorization_code\"," - + " \"refresh_token\"" - + " ]" - + "}"; - - private static final Set SET_FIELDS = - Set.of("token_endpoint_auth_methods_supported", "scopes_supported", "grant_types_supported"); - private static final List FIELDS = List.of( - "authorization_endpoint", - "token_endpoint", - "userinfo_endpoint", - "jwks_uri", - "scopes_supported", - "grant_types_supported", - "token_endpoint_auth_methods_supported"); - - @Test - public void parseExplicitKeys() throws IOException { - WellKnownOpenIDConfigurationResponse response = GsonFactory.getDefaultInstance() - .fromString(JSON_FROM_GOOGLE, WellKnownOpenIDConfigurationResponse.class); - - assertThat(response.getAuthorizationEndpoint(), is("https://accounts.google.com/o/oauth2/v2/auth")); - assertThat(response.getTokenEndpoint(), is("https://www.googleapis.com/oauth2/v4/token")); - assertThat(response.getUserinfoEndpoint(), is("https://www.googleapis.com/oauth2/v3/userinfo")); - assertThat(response.getJwksUri(), is("https://www.googleapis.com/oauth2/v3/certs")); - assertThat(response.getScopesSupported(), containsInAnyOrder("openid", "email", "profile")); - assertThat(response.getTokenAuthMethods(), containsInAnyOrder("client_secret_basic", "client_secret_post")); - assertThat(response.getGrantTypesSupported(), containsInAnyOrder("authorization_code", "refresh_token")); - } - - @Test - public void parseWellKnownKeys() throws IOException { - WellKnownOpenIDConfigurationResponse response = GsonFactory.getDefaultInstance() - .fromString(JSON_FROM_GOOGLE, WellKnownOpenIDConfigurationResponse.class); - assertThat(response.getKnownKeys().keySet(), containsInAnyOrder(FIELDS.toArray(new String[0]))); - } - - @Test - public void testEquals() { - WellKnownOpenIDConfigurationResponse obj1 = new WellKnownOpenIDConfigurationResponse(); - assertNotEquals(obj1, new Object()); - WellKnownOpenIDConfigurationResponse obj2 = new WellKnownOpenIDConfigurationResponse(); - assertEquals(obj1, obj1); - assertEquals(obj1, obj2); - - testField(obj1, obj2, "userinfo_endpoint", "some userinfo endpoint"); - testField(obj1, obj2, "authorization_endpoint", "some auth endpoint"); - testField(obj1, obj2, "token_endpoint", "some token_endpoint endpoint"); - testField(obj1, obj2, "jwks_uri", "some jwks_uri endpoint"); - testField(obj1, obj2, "end_session_endpoint", "some end_session_endpoint endpoint"); - } - - private void testField( - WellKnownOpenIDConfigurationResponse obj1, - WellKnownOpenIDConfigurationResponse obj2, - String field, - String value) { - obj1.set(field, value); - obj2.set(field, null); - assertNotEquals(obj1, obj2); - - obj1.set(field, null); - obj2.set(field, value); - assertNotEquals(obj1, obj2); - - obj1.set(field, value + "1"); - obj2.set(field, value); - assertNotEquals(obj1, obj2); - - obj1.set(field, value); - obj2.set(field, value + "1"); - assertNotEquals(obj1, obj2); - - obj1.set(field, null); - obj2.set(field, null); - assertEquals(obj1, obj2); - - obj1.set(field, value); - obj2.set(field, value); - assertEquals(obj1, obj2); - } - - @Test - public void testHashcode() { - WellKnownOpenIDConfigurationResponse obj1 = new WellKnownOpenIDConfigurationResponse(); - var currentHashCode = obj1.hashCode(); - for (String field : FIELDS) { - if (SET_FIELDS.contains(field)) { - obj1.set(field, Set.of(UUID.randomUUID().toString())); - } else { - obj1.set(field, UUID.randomUUID().toString()); - } - var previousHashCode = currentHashCode; - currentHashCode = obj1.hashCode(); - assertNotEquals("should be different after setting " + field, previousHashCode, currentHashCode); - } - } -} diff --git a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml index c223904a..fbd70922 100644 --- a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml +++ b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCode.yml @@ -4,6 +4,7 @@ jenkins: serverConfiguration: manual: authorizationServerUrl: http://localhost/authorize + issuer: http://localhost/ jwksServerUrl: http://localhost/jwks tokenAuthMethod: client_secret_post tokenServerUrl: http://localhost/token diff --git a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml index 5919a30e..b8d603a2 100644 --- a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml +++ b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeExport.yml @@ -13,6 +13,7 @@ sendScopesInTokenRequest: true serverConfiguration: manual: authorizationServerUrl: "http://localhost/authorize" + issuer: "http://localhost/" jwksServerUrl: "http://localhost/jwks" scopes: "scopes" tokenServerUrl: "http://localhost/token" diff --git a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeMinimal.yml b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeMinimal.yml index dee70839..6a42c104 100644 --- a/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeMinimal.yml +++ b/src/test/resources/org/jenkinsci/plugins/oic/ConfigurationAsCodeMinimal.yml @@ -3,6 +3,7 @@ jenkins: oic: serverConfiguration: manual: + issuer: http://localhost/ authorizationServerUrl: http://localhost/authorize tokenServerUrl: http://localhost/token clientId: clientId