diff --git a/pom.xml b/pom.xml index f4315b86..7546c6f8 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 + 4.364 + + 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/OicCrumbExclusion.java b/src/main/java/org/jenkinsci/plugins/oic/OicCrumbExclusion.java index 7d33bbe8..d7e0141e 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicCrumbExclusion.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicCrumbExclusion.java @@ -11,7 +11,7 @@ import jenkins.model.Jenkins; /** - * Crumb exclusion to allow POSTing to {@link OicSecurityRealm#doFinishLogin(org.kohsuke.stapler.StaplerRequest)} + * Crumb exclusion to allow POSTing to {@link OicSecurityRealm#doFinishLogin(org.kohsuke.stapler.StaplerRequest, org.kohsuke.stapler.StaplerResponse)} */ @Extension public class OicCrumbExclusion extends CrumbExclusion { 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 aabbfddd..0ade47f5 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,34 @@ 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 +140,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; @@ -215,7 +227,7 @@ public static enum TokenAuthMethod { private OicServerConfiguration serverConfiguration; - /** @deprecated see {@link OicServerWellKnownConfiguration#getScopes()} */ + /** @deprecated with no replacement. See sub classes of {@link OicServerConfiguration} */ @Deprecated private String overrideScopes = null; @@ -259,12 +271,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 +284,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 +298,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 +305,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 +334,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 +355,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 +456,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 +560,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 +720,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 * @@ -736,122 +752,27 @@ protected String getValidRedirectUrl(String url) { * Handles the the securityRealm/commenceLogin resource and sends the user off to the IdP * @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())); - } + public void doCommenceLogin(@QueryParameter String from, @Header("Referer") final String referer) + throws URISyntaxException { - 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); - - 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; - } - }) - .setIssuer(getServerConfiguration().getIssuer()) - .setAudience(List.of(clientId))); - } - return jwtVerifier; - } + OidcClient client = buildOidcClient(); + // add the extra params for the client... + final String redirectOnFinish = getValidRedirectUrl(from != null ? from : referer); - /** 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( @@ -866,52 +787,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)) { @@ -953,7 +845,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); @@ -965,7 +857,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; } @@ -984,7 +877,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) { @@ -1003,7 +897,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); @@ -1028,7 +922,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) { @@ -1044,13 +938,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)) { @@ -1080,7 +974,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())); @@ -1092,10 +987,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); @@ -1109,15 +1000,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("&"); @@ -1171,15 +1070,60 @@ 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 if the JWT (or other response) could not be parsed. */ - 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 { + 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()); } /** @@ -1227,7 +1171,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); @@ -1262,113 +1207,75 @@ 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); - - 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; + WebContext webContext = JEEContextFactory.INSTANCE.newContext(httpRequest, httpResponse); + SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore(); + OidcClient client = buildOidcClient(); try { - parsedIdToken = tokenResponse.parseIdToken(); - } catch (IllegalArgumentException e) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, Messages.OicSecurityRealm_IdTokenParseError()); - return false; - } - - if (!validateIdToken(parsedIdToken)) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); - return false; - } - - if (failedCheckOfTokenField(parsedIdToken)) { - httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); - 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")); + + 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 resort 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; } } @Extension public static final class DescriptorImpl extends Descriptor { + @Override public String getDisplayName() { return Messages.OicSecurityRealm_DisplayName(); } diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java b/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java index 889c7c73..1251fd43 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicServerConfiguration.java @@ -1,40 +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(); - - @CheckForNull - public abstract String getIssuer(); - - 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 7bc17623..7ba40f7d 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; @@ -35,7 +48,9 @@ public class OicServerManualConfiguration extends OicServerConfiguration { private String issuer; @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); } @@ -50,11 +65,6 @@ public void setEndSessionUrl(@Nullable String endSessionUrl) { this.endSessionUrl = endSessionUrl; } - @DataBoundSetter - public void setIssuer(@Nullable String issuer) { - this.issuer = Util.fixEmptyAndTrim(issuer); - } - @DataBoundSetter public void setJwksServerUrl(@Nullable String jwksServerUrl) { this.jwksServerUrl = jwksServerUrl; @@ -75,53 +85,90 @@ public void setUseRefreshTokens(boolean useRefreshTokens) { this.useRefreshTokens = useRefreshTokens; } - @Override public String getAuthorizationServerUrl() { return authorizationServerUrl; } - @Override - @CheckForNull public String getEndSessionUrl() { return endSessionUrl; } - @Override - @CheckForNull public String getIssuer() { return issuer; } - @Override 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); @@ -129,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 { @@ -170,7 +231,7 @@ public FormValidation doCheckEndSessionUrl(@QueryParameter String value) { public FormValidation doCheckIssuer(@QueryParameter String issuer) { Jenkins.get().checkPermission(Jenkins.ADMINISTER); if (Util.fixEmptyAndTrim(issuer) == null) { - return FormValidation.warning(Messages.OicSecurityRealm_IssuerRecommended()); + return FormValidation.warning(Messages.OicSecurityRealm_IssuerRequired()); } return FormValidation.ok(); } diff --git a/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java b/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java index 42980431..adf91584 100644 --- a/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java +++ b/src/main/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfiguration.java @@ -1,11 +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 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 edu.umd.cs.findbugs.annotations.CheckForNull; import hudson.Extension; import hudson.RelativePath; @@ -15,17 +14,22 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; -import java.nio.charset.Charset; +import java.net.http.HttpHeaders; 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.Optional; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; +import javax.net.ssl.SSLException; 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.DoNotUse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; @@ -40,21 +44,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; - private transient String issuer; - /** * 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); @@ -65,49 +61,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 - @CheckForNull - public String getIssuer() { - loadWellKnownConfigIfNeeded(); - return issuer; - } - - @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; } @@ -116,97 +69,98 @@ 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(DoNotUse.class) // for testing only + void invalidateProviderMetadata() { + 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.issuer = config.getIssuer(); - 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; + // we have no access to the HTTP Headers to be able to find a expirey headers. + // for now use the default expirey of 1hr. + // we are already calling HTTP endpoints as part of the flow, so making one extra call an hour + // should not cause any issues. + // once this is validate, the OicSecurityRealm can be simplified to cache the built client + // and have a periodic task to invalidate it when auto config is being used. + setWellKnownExpires(null); + 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 + * Parse headers to determine expiration date. + * Sets the expiry time to 1 hour from the current time if the header is not available. */ - private void setWellKnownExpires(HttpHeaders headers) { - String expires = Util.fixEmptyAndTrim(headers.getExpires()); + private void setWellKnownExpires(@CheckForNull HttpHeaders headers) { + Optional expires = headers == null ? Optional.empty() : headers.firstValue("Expires"); // expires 0 means no cache // we could (should?) have a look at Cache-Control header and max-age but for - // simplicity - // we can just leave it default TTL 1h refresh which sounds reasonable for such - // file - if (expires != null && !"0".equals(expires)) { - ZonedDateTime zdt = ZonedDateTime.parse(expires, DateTimeFormatter.RFC_1123_DATE_TIME); + // simplicity we can just leave it default TTL 1h refresh which sounds reasonable for such file + if (expires.isPresent() && !"0".equals(expires.get())) { + ZonedDateTime zdt = ZonedDateTime.parse(expires.get(), DateTimeFormatter.RFC_1123_DATE_TIME); if (zdt != null) { this.wellKnownExpires = zdt.toLocalDateTime(); return; @@ -235,36 +189,24 @@ 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(); + ProxyAwareResourceRetriever prr = + ProxyAwareResourceRetriever.createProxyAwareResourceRetriver(disableSslVerification); - // 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) { + OIDCProviderMetadata providerMetadata = + OIDCProviderMetadata.parse(prr.retrieveResource(new URL(wellKnownOpenIDConfigurationUrl)) + .getContent()); + + 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 (SSLException e) { + return FormValidation.error(e, Messages.OicSecurityRealm_SSLErrorRetreivingWellKnownConfig()); } catch (IOException e) { return FormValidation.error(e, Messages.OicSecurityRealm_ErrorRetreivingWellKnownConfig()); + } catch (ParseException e) { + return FormValidation.error(e, Messages.OicSecurityRealm_URLNotAOpenIdEnpoint()); } } 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 04e87253..00000000 --- a/src/main/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponse.java +++ /dev/null @@ -1,171 +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("issuer") - private String issuer; - - @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 getIssuer() { - return issuer; - } - - 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(issuer, obj.getIssuer())) { - 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 - + issuer - + 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 ed4ab7e6..da27d25c 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 @@ -8,7 +9,6 @@ OicSecurityRealm.NotAValidURL = Not a valid url. OicSecurityRealm.CouldNotRetreiveWellKnownConfig = Could not retrieve well-known config {0,number,integer} {1} OicSecurityRealm.CouldNotParseResponse = Could not parse response OicSecurityRealm.ErrorRetreivingWellKnownConfig = Error when retrieving well-known config -OicSecurityRealm.IssuerRecommended = For security reasons it is strongly recommended to provide an issuer. OicSecurityRealm.TokenServerURLKeyRequired = Token Server Url Key is required. OicSecurityRealm.TokenAuthMethodRequired = Token auth method is required. OicSecurityRealm.UsingDefaultUsername = Using ''sub''. @@ -16,9 +16,12 @@ 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.SSLErrorRetreivingWellKnownConfig = The server presented an invalid or incorrect TLS certificate. 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-subjectType.html b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-subjectType.html new file mode 100644 index 00000000..10c934a2 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicSecurityRealm/help-subjectType.html @@ -0,0 +1,5 @@ +
+ The Subject Identifier 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/OicServerManualConfiguration/config.jelly b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/config.jelly index 7c7a91b9..88778ae5 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 @@ + + + + + @@ -12,9 +17,6 @@ checked="${instance.tokenAuthMethod == null || instance.tokenAuthMethod == 'client_secret_post'}" value="client_secret_post" inline="true" help="${null}"/> - - - 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 index 7209bfcc..fce0abbd 100644 --- a/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer.html +++ b/src/main/resources/org/jenkinsci/plugins/oic/OicServerManualConfiguration/help-issuer.html @@ -1,3 +1,3 @@
- Strongly recommended. The received ID Token's issuer must match the specified issuer. + Required. The received ID Token's issuer must match the specified issuer.
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 index 0e98f08e..570c189a 100644 --- 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 @@ -1,3 +1,3 @@
- Fortement recommandé. Le Token ID reçu doit avoir l'émetteur indiqué. + Obligatoire. Le Token ID reçu doit avoir l'émetteur indiqué.
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..3d1e103c 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfigurationTest.java +++ b/src/test/java/org/jenkinsci/plugins/oic/OicServerWellKnownConfigurationTest.java @@ -28,7 +28,8 @@ public class OicServerWellKnownConfigurationTest { public static JenkinsRule jenkinsRule = new JenkinsRule(); @Rule - public WireMockRule wireMockRule = new WireMockRule(new WireMockConfiguration().dynamicPort(), true); + public WireMockRule wireMockRule = + new WireMockRule(new WireMockConfiguration().dynamicPort().dynamicHttpsPort(), true); @Test public void doCheckWellKnownOpenIDConfigurationUrl() throws IOException { @@ -43,17 +44,36 @@ public void doCheckWellKnownOpenIDConfigurationUrl() throws IOException { allOf(hasKind(FormValidation.Kind.ERROR), withMessage("Not a valid url."))); assertThat( descriptor.doCheckWellKnownOpenIDConfigurationUrl( - wireMockRule.url("/.well-known/openid-configuration"), false), + "http://localhost:" + wireMockRule.port() + ("/.well-known/openid-configuration"), false), hasKind(FormValidation.Kind.OK)); + assertThat( + descriptor.doCheckWellKnownOpenIDConfigurationUrl( + wireMockRule.url("/.well-known/openid-configuration"), true), // disable TLS + hasKind(FormValidation.Kind.OK)); + // TLS error. + assertThat( + descriptor.doCheckWellKnownOpenIDConfigurationUrl( + wireMockRule.url("/.well-known/openid-configuration"), false), + allOf( + hasKind(FormValidation.Kind.ERROR), + withMessageContaining("The server presented an invalid or incorrect TLS certificate"))); assertThat( 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 +99,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 +107,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 94348b6c..8c2a2d00 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.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.Url; @@ -83,9 +87,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 { @@ -98,7 +103,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(); @@ -110,6 +119,9 @@ public class PluginTest { public void setUp() { jenkins = jenkinsRule.getInstance(); webClient = jenkinsRule.createWebClient(); + if (new DisableOnDebug(null).isDebugging()) { + webClient.getOptions().setTimeout(0); + } } @Test @@ -129,8 +141,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; }); } @@ -167,13 +178,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(); @@ -198,13 +213,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) @@ -212,7 +227,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); @@ -307,18 +322,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 @@ -329,7 +351,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 @@ -522,7 +547,6 @@ private void expire() throws Exception { 60L, 1L, 60L)); - return null; }); } @@ -599,6 +623,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(); @@ -840,24 +865,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 @@ -915,13 +954,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()); @@ -964,7 +1003,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 @@ -993,38 +1032,12 @@ public void loginWithCheckTokenFailure() throws Exception { public void loginWithIncorrectIssuerFails() throws Exception { mockAuthorizationRedirectsToFinishLogin(); mockTokenReturnsIdTokenWithGroup(); - jenkins.setSecurityRealm( - new TestRealm.Builder(wireMockRule).WithIssuer("another_issuer").build()); - assertAnonymous(); - webClient.setThrowExceptionOnFailingStatusCode(false); - browseLoginPage(); - assertAnonymous(); - } - - @Test - @Issue("SECURITY-3441") - public void loginWithoutIssuerSetSucceeds() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - jenkins.setSecurityRealm( - new TestRealm.Builder(wireMockRule).WithIssuer(null).build()); + jenkins.setSecurityRealm(new TestRealm.Builder(wireMockRule) + .WithIssuer("another_issuer").WithDisableTokenValidation(false).build()); assertAnonymous(); webClient.setThrowExceptionOnFailingStatusCode(false); browseLoginPage(); - assertTestUser(); - } - - @Test - @Issue("SECURITY-3441") - public void loginWithEmptyIssuerSetSucceeds() throws Exception { - mockAuthorizationRedirectsToFinishLogin(); - mockTokenReturnsIdTokenWithGroup(); - jenkins.setSecurityRealm( - new TestRealm.Builder(wireMockRule).WithIssuer(null).build()); assertAnonymous(); - webClient.setThrowExceptionOnFailingStatusCode(false); - browseLoginPage(); - assertTestUser(); } @Test @@ -1033,7 +1046,9 @@ public void loginWithIncorrectAudienceFails() throws Exception { mockAuthorizationRedirectsToFinishLogin(); mockTokenReturnsIdTokenWithGroup(); jenkins.setSecurityRealm(new TestRealm.Builder(wireMockRule) - .WithClient("another_client_id", "client_secret").build()); + .WithClient("another_client_id", "client_secret") + .WithDisableTokenValidation(false) + .build()); assertAnonymous(); webClient.setThrowExceptionOnFailingStatusCode(false); browseLoginPage(); @@ -1151,9 +1166,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); } @@ -1231,7 +1244,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 fd6235b7..e294b928 100644 --- a/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java +++ b/src/test/java/org/jenkinsci/plugins/oic/TestRealm.java @@ -7,8 +7,14 @@ import io.burt.jmespath.Expression; import java.io.IOException; import java.io.ObjectStreamException; -import org.kohsuke.stapler.HttpResponse; +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,11 +22,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 issuer = ISSUER; public String wellKnownOpenIDConfigurationUrl; public String tokenServerUrl; public String jwksServerUrl = null; @@ -43,6 +50,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() + "/"); @@ -122,6 +130,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); } @@ -137,7 +150,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) { @@ -145,7 +158,6 @@ public OicServerConfiguration buildServerConfiguration() { } conf.setJwksServerUrl(jwksServerUrl); conf.setEndSessionUrl(endSessionEndpoint); - conf.setIssuer(issuer); return conf; } catch (Exception e) { throw new IllegalArgumentException(e); @@ -171,6 +183,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) @@ -230,12 +247,18 @@ 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 { + /* + * 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 9cf1c184..00000000 --- a/src/test/java/org/jenkinsci/plugins/oic/WellKnownOpenIDConfigurationResponseTest.java +++ /dev/null @@ -1,167 +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", - "issuer", - "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