Skip to content

feat: SAML2 logout support #471

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 2 additions & 64 deletions src/main/java/org/jenkinsci/plugins/saml/OpenSAMLWrapper.java
Original file line number Diff line number Diff line change
@@ -17,9 +17,7 @@

package org.jenkinsci.plugins.saml;

import java.util.Arrays;
import java.util.logging.Logger;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.StaplerRequest2;
import org.kohsuke.stapler.StaplerResponse2;
import org.opensaml.core.config.InitializationException;
@@ -48,7 +46,6 @@
*/
public abstract class OpenSAMLWrapper<T> {
private static final Logger LOG = Logger.getLogger(OpenSAMLWrapper.class.getName());
private static final BundleKeyStore KS = new BundleKeyStore();

protected SamlPluginConfig samlPluginConfig;
protected StaplerRequest2 request;
@@ -102,70 +99,11 @@ protected SessionStore createSessionStore() {
* @return a SAML2Client object to interact with the IdP service.
*/
protected SAML2Client createSAML2Client() {
SAML2Configuration config = new SAML2Configuration();
config.setIdentityProviderMetadataResource(new SamlFileResource(SamlSecurityRealm.getIDPMetadataFilePath()));
config.setAuthnRequestBindingType(samlPluginConfig.getBinding());

SamlEncryptionData encryptionData = samlPluginConfig.getEncryptionData();
if (encryptionData != null) {
config.setAuthnRequestSigned(encryptionData.isForceSignRedirectBindingAuthnRequest());
config.setWantsAssertionsSigned(encryptionData.isWantsAssertionsSigned());
} else {
config.setAuthnRequestSigned(false);
config.setWantsAssertionsSigned(false);
}

if(encryptionData != null && StringUtils.isNotBlank(encryptionData.getKeystorePath())){
config.setKeystorePath(encryptionData.getKeystorePath());
config.setKeystorePassword(encryptionData.getKeystorePasswordPlainText());
config.setPrivateKeyPassword(encryptionData.getPrivateKeyPasswordPlainText());
config.setKeyStoreAlias(encryptionData.getPrivateKeyAlias());
} else {
if (!KS.isValid()) {
KS.init();
}
if (KS.isUsingDemoKeyStore()) {
LOG.warning("Using bundled keystore : " + KS.getKeystorePath());
}
config.setKeystorePath(KS.getKeystorePath());
config.setKeystorePassword(KS.getKsPassword());
config.setPrivateKeyPassword(KS.getKsPkPassword());
config.setKeyStoreAlias(KS.getKsPkAlias());
}

config.setMaximumAuthenticationLifetime(samlPluginConfig.getMaximumAuthenticationLifetime());
// tolerate missing SAML response Destination attribute https://github.com/pac4j/pac4j/pull/1871
config.setResponseDestinationAttributeMandatory(false);

SamlAdvancedConfiguration advancedConfiguration = samlPluginConfig.getAdvancedConfiguration();
if (advancedConfiguration != null) {

// request forced authentication at the IdP, if selected
config.setForceAuth(samlPluginConfig.getForceAuthn());

// override the default EntityId for this SP, if one is set
if (samlPluginConfig.getSpEntityId() != null) {
config.setServiceProviderEntityId(samlPluginConfig.getSpEntityId());
}

// if a specific authentication type (authentication context class
// reference) is set, include it in the request to the IdP, and request
// that the IdP uses exact matching for authentication types
if (samlPluginConfig.getAuthnContextClassRef() != null) {
config.setAuthnContextClassRefs(Arrays.asList(samlPluginConfig.getAuthnContextClassRef()));
config.setComparisonType("exact");
}

if(samlPluginConfig.getNameIdPolicyFormat() != null) {
config.setNameIdPolicyFormat(samlPluginConfig.getNameIdPolicyFormat());
}
}

config.setForceServiceProviderMetadataGeneration(true);
config.setServiceProviderMetadataResource(new SamlFileResource(SamlSecurityRealm.getSPMetadataFilePath()));
SAML2Configuration config = samlPluginConfig.getSAML2Configuration();
SAML2Client saml2Client = new SAML2Client(config);
saml2Client.setCallbackUrl(samlPluginConfig.getConsumerServiceUrl());
saml2Client.setCallbackUrlResolver(new NoParameterCallbackUrlResolver());
SamlAdvancedConfiguration advancedConfiguration = samlPluginConfig.getAdvancedConfiguration();
if(advancedConfiguration != null && advancedConfiguration.getRandomRelayState()){
saml2Client.setStateGenerator(new RandomValueGenerator());
}
84 changes: 84 additions & 0 deletions src/main/java/org/jenkinsci/plugins/saml/SamlLogoutWrapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* Licensed to Jenkins CI under one or more contributor license
agreements. See the NOTICE file distributed with this work
for additional information regarding copyright ownership.
Jenkins CI licenses this file to you under the Apache License,
Version 2.0 (the "License"); you may not use this file except
in compliance with the License. You may obtain a copy of the
License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License. */

package org.jenkinsci.plugins.saml;

import java.util.logging.Logger;
import org.kohsuke.stapler.StaplerRequest2;
import org.kohsuke.stapler.StaplerResponse2;
import org.pac4j.core.context.CallContext;
import org.pac4j.core.context.WebContext;
import org.pac4j.core.context.session.SessionStore;
import org.pac4j.core.exception.http.HttpAction;
import org.pac4j.core.exception.http.RedirectionAction;
import org.pac4j.saml.client.SAML2Client;
import org.pac4j.saml.credentials.SAML2AuthenticationCredentials;
import org.pac4j.saml.credentials.SAML2Credentials;
import org.pac4j.saml.exceptions.SAMLException;
import org.pac4j.saml.logout.SAML2LogoutActionBuilder;
import org.pac4j.saml.profile.SAML2Profile;
import org.springframework.security.authentication.BadCredentialsException;

/**
* Process to response from the IdP to obtain the SAML2Profile of the user.
*/
public class SamlLogoutWrapper extends OpenSAMLWrapper<RedirectionAction> {
private static final Logger LOG = Logger.getLogger(SamlProfileWrapper.class.getName());
private String targetURL;


public SamlLogoutWrapper(SamlPluginConfig samlPluginConfig, StaplerRequest2 request, StaplerResponse2 response, String targetURL) {
this.request = request;
this.response = response;
this.samlPluginConfig = samlPluginConfig;
this.targetURL = targetURL;
}

/**
* @return the SAML2Profile of the user returned by the IdP.
*/
@SuppressWarnings("unused")
@Override
protected RedirectionAction process() {
SAML2AuthenticationCredentials credentials;
SAML2Profile saml2Profile;
RedirectionAction logOutAction;
try {
SAML2Client client = createSAML2Client();
WebContext context = createWebContext();
SessionStore sessionStore = createSessionStore();
CallContext ctx = new CallContext(context, sessionStore);
SAML2Credentials unvalidated = (SAML2Credentials) client.getCredentials(ctx).orElse(null);
credentials = (SAML2AuthenticationCredentials) client.validateCredentials(ctx, unvalidated).orElse(null);
saml2Profile = (SAML2Profile) client.getUserProfile(ctx, credentials).orElse(null);
SAML2LogoutActionBuilder logoutActionBuilder = new SAML2LogoutActionBuilder(client);
logOutAction = logoutActionBuilder.getLogoutAction(ctx, saml2Profile, this.targetURL).get();
client.destroy();
} catch (HttpAction|SAMLException e) {
//if the SAMLResponse is not valid we send the user again to the IdP
throw new BadCredentialsException(e.getMessage(), e);
}
if (logOutAction == null) {
String msg = "Could not build logout action for SAML";
LOG.severe(msg);
throw new BadCredentialsException(msg);
}

LOG.finer(logOutAction.toString());
return logOutAction;

Check warning on line 82 in src/main/java/org/jenkinsci/plugins/saml/SamlLogoutWrapper.java

ci.jenkins.io / Code Coverage

Not covered lines

Lines 40-82 are not covered by tests
}
}
72 changes: 72 additions & 0 deletions src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java
Original file line number Diff line number Diff line change
@@ -18,14 +18,22 @@
package org.jenkinsci.plugins.saml;

import org.apache.commons.lang.StringUtils;
import org.pac4j.saml.config.SAML2Configuration;

import jenkins.model.Jenkins;
import static org.jenkinsci.plugins.saml.SamlSecurityRealm.CONSUMER_SERVICE_URL_PATH;
import static org.jenkinsci.plugins.saml.SamlSecurityRealm.DEFAULT_USERNAME_CASE_CONVERSION;

import java.util.Arrays;
import java.util.logging.Logger;

/**
* contains all the Jenkins SAML Plugin settings
*/
public class SamlPluginConfig {
private static final BundleKeyStore KS = new BundleKeyStore();
private static final Logger LOG = Logger.getLogger(SamlPluginConfig.class.getName());

private final String displayNameAttributeName;
private final String groupsAttributeName;
private final int maximumAuthenticationLifetime;
@@ -126,6 +134,70 @@
return binding;
}

public SAML2Configuration getSAML2Configuration(){
SAML2Configuration config = new SAML2Configuration();
config.setIdentityProviderMetadataResource(new SamlFileResource(SamlSecurityRealm.getIDPMetadataFilePath()));
config.setAuthnRequestBindingType(getBinding());

SamlEncryptionData encryptionData = getEncryptionData();
if (encryptionData != null) {
config.setAuthnRequestSigned(encryptionData.isForceSignRedirectBindingAuthnRequest());
config.setWantsAssertionsSigned(encryptionData.isWantsAssertionsSigned());
} else {
config.setAuthnRequestSigned(false);
config.setWantsAssertionsSigned(false);
}

if(encryptionData != null && StringUtils.isNotBlank(encryptionData.getKeystorePath())){

Check warning on line 151 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Partially covered line

Line 151 is only partially covered, one branch is missing
config.setKeystorePath(encryptionData.getKeystorePath());
config.setKeystorePassword(encryptionData.getKeystorePasswordPlainText());
config.setPrivateKeyPassword(encryptionData.getPrivateKeyPasswordPlainText());
config.setKeyStoreAlias(encryptionData.getPrivateKeyAlias());
} else {
if (!KS.isValid()) {

Check warning on line 157 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Partially covered line

Line 157 is only partially covered, one branch is missing
KS.init();
}
if (KS.isUsingDemoKeyStore()) {

Check warning on line 160 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Partially covered line

Line 160 is only partially covered, one branch is missing
LOG.warning("Using bundled keystore : " + KS.getKeystorePath());

Check warning on line 161 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Not covered line

Line 161 is not covered by tests
}
config.setKeystorePath(KS.getKeystorePath());
config.setKeystorePassword(KS.getKsPassword());
config.setPrivateKeyPassword(KS.getKsPkPassword());
config.setKeyStoreAlias(KS.getKsPkAlias());
}

config.setMaximumAuthenticationLifetime(getMaximumAuthenticationLifetime());
// tolerate missing SAML response Destination attribute https://github.com/pac4j/pac4j/pull/1871
config.setResponseDestinationAttributeMandatory(false);

if (getAdvancedConfiguration() != null) {

// request forced authentication at the IdP, if selected
config.setForceAuth(getForceAuthn());

// override the default EntityId for this SP, if one is set
if (getSpEntityId() != null) {

Check warning on line 179 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Partially covered line

Line 179 is only partially covered, one branch is missing
config.setServiceProviderEntityId(getSpEntityId());
}

// if a specific authentication type (authentication context class
// reference) is set, include it in the request to the IdP, and request
// that the IdP uses exact matching for authentication types
if (getAuthnContextClassRef() != null) {

Check warning on line 186 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Partially covered line

Line 186 is only partially covered, one branch is missing
config.setAuthnContextClassRefs(Arrays.asList(getAuthnContextClassRef()));
config.setComparisonType("exact");

Check warning on line 188 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Not covered lines

Lines 187-188 are not covered by tests
}

if(getNameIdPolicyFormat() != null) {

Check warning on line 191 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Partially covered line

Line 191 is only partially covered, one branch is missing
config.setNameIdPolicyFormat(getNameIdPolicyFormat());

Check warning on line 192 in src/main/java/org/jenkinsci/plugins/saml/SamlPluginConfig.java

ci.jenkins.io / Code Coverage

Not covered line

Line 192 is not covered by tests
}
}

config.setForceServiceProviderMetadataGeneration(true);
config.setServiceProviderMetadataResource(new SamlFileResource(SamlSecurityRealm.getSPMetadataFilePath()));
return config;
}

@Override
public String toString() {
return "SamlPluginConfig{" + "idpMetadataConfiguration='" + getIdpMetadataConfiguration() + '\''
Loading