KEYCLOAK-10776 Add session expiration to Keycloak saml login response

This commit is contained in:
mhajas 2019-07-08 09:21:55 +02:00 committed by Hynek Mlnařík
parent 4b18c6a117
commit 57a8fcb669
7 changed files with 246 additions and 3 deletions

View file

@ -60,6 +60,7 @@ public class SAML2LoginResponseBuilder implements SamlProtocolExtensionsAwareBui
protected String issuer;
protected int subjectExpiration;
protected int assertionExpiration;
protected int sessionExpiration;
protected String nameId;
protected String nameIdFormat;
protected boolean multiValuedRoles;
@ -98,6 +99,18 @@ public class SAML2LoginResponseBuilder implements SamlProtocolExtensionsAwareBui
return this;
}
/**
* Length of time in seconds the idp session will be valid
* See SAML core specification 2.7.2 SessionNotOnOrAfter
*
* @param sessionExpiration Number of seconds the session should be valid
* @return
*/
public SAML2LoginResponseBuilder sessionExpiration(int sessionExpiration) {
this.sessionExpiration = sessionExpiration;
return this;
}
/**
* Length of time in seconds the assertion is valid for
* See SAML core specification 2.5.1.2 NotOnOrAfter
@ -217,6 +230,10 @@ public class SAML2LoginResponseBuilder implements SamlProtocolExtensionsAwareBui
AuthnStatementType authnStatement = StatementUtil.createAuthnStatement(XMLTimeUtil.getIssueInstant(),
authContextRef);
if (sessionExpiration > 0)
authnStatement.setSessionNotOnOrAfter(XMLTimeUtil.add(authnStatement.getAuthnInstant(), sessionExpiration * 1000));
if (sessionIndex != null) authnStatement.setSessionIndex(sessionIndex);
else authnStatement.setSessionIndex(assertion.getID());

View file

@ -390,8 +390,15 @@ public class SamlProtocol implements LoginProtocol {
clientSession.setNote(SAML_NAME_ID_FORMAT, nameIdFormat);
SAML2LoginResponseBuilder builder = new SAML2LoginResponseBuilder();
builder.requestID(requestID).destination(redirectUri).issuer(responseIssuer).assertionExpiration(realm.getAccessCodeLifespan()).subjectExpiration(realm.getAccessTokenLifespan())
.requestIssuer(clientSession.getClient().getClientId()).nameIdentifier(nameIdFormat, nameId).authMethod(JBossSAMLURIConstants.AC_UNSPECIFIED.get());
builder.requestID(requestID)
.destination(redirectUri)
.issuer(responseIssuer)
.assertionExpiration(realm.getAccessCodeLifespan())
.subjectExpiration(realm.getAccessTokenLifespan())
.sessionExpiration(realm.getSsoSessionMaxLifespan())
.requestIssuer(clientSession.getClient().getClientId())
.nameIdentifier(nameIdFormat, nameId)
.authMethod(JBossSAMLURIConstants.AC_UNSPECIFIED.get());
String sessionIndex = SamlSessionUtils.getSessionIndex(clientSession);
builder.sessionIndex(sessionIndex);

View file

@ -16,6 +16,7 @@ import org.keycloak.saml.processing.core.saml.v2.util.XMLTimeUtil;
import org.keycloak.testsuite.adapter.filter.AdapterActionsFilter;
import org.keycloak.testsuite.adapter.page.Employee2Servlet;
import org.keycloak.testsuite.arquillian.annotation.AppServerContainer;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.SamlClient;
import org.keycloak.testsuite.util.SamlClientBuilder;
@ -27,7 +28,10 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertThat;
import static org.keycloak.testsuite.saml.AbstractSamlTest.REALM_NAME;
import static org.keycloak.testsuite.util.Matchers.bodyHC;
@ -146,4 +150,54 @@ public class SAMLServletSessionTimeoutTest extends AbstractSAMLServletAdapterTes
setAdapterAndServerTimeOffset(0, employee2ServletPage.toString());
}
@Test
public void testKeycloakReturnsSessionNotOnOrAfter() throws Exception {
sessionNotOnOrAfter.set(null);
try(AutoCloseable c = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
.updateWith(r -> r.setSsoSessionMaxLifespan(SESSION_LENGTH_IN_SECONDS))
.update()) {
beginAuthenticationAndLogin()
.processSamlResponse(SamlClient.Binding.POST) // Process response
.transformObject(ob -> { // Check sessionNotOnOrAfter is present and it has correct value
assertThat(ob, Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType resp = (ResponseType) ob;
Set<StatementAbstractType> statements = resp.getAssertions().get(0).getAssertion().getStatements();
AuthnStatementType authType = (AuthnStatementType) statements.stream()
.filter(statement -> statement instanceof AuthnStatementType)
.findFirst().orElseThrow(() -> new RuntimeException("SamlReponse doesn't contain AuthStatement"));
assertThat(authType.getSessionNotOnOrAfter(), notNullValue());
XMLGregorianCalendar expectedSessionTimeout = XMLTimeUtil.add(authType.getAuthnInstant(), SESSION_LENGTH_IN_SECONDS * 1000);
assertThat(authType.getSessionNotOnOrAfter(), is(expectedSessionTimeout));
sessionNotOnOrAfter.set(expectedSessionTimeout.toString());
return ob;
})
.build()
.navigateTo(employee2ServletPage.buildUri())
.assertResponse(response -> // Check that session is still valid within sessionTimeout limit
assertThat(response, // Cannot use matcher as sessionNotOnOrAfter variable is not set in time of creating matcher
bodyHC(allOf(containsString("principal=bburke"),
containsString("SessionNotOnOrAfter: " + sessionNotOnOrAfter.get())))))
.addStep(() -> setAdapterAndServerTimeOffset(KEYCLOAK_SESSION_TIMEOUT, employee2ServletPage.toString())) // Move in time after sessionNotOnOrAfter and keycloak session
.navigateTo(employee2ServletPage.buildUri())
.processSamlResponse(SamlClient.Binding.POST) // AuthnRequest should be send
.transformObject(ob -> {
assertThat(ob, Matchers.isSamlAuthnRequest());
return ob;
})
.build()
.followOneRedirect() // There is a redirect on Keycloak side
.assertResponse(Matchers.bodyHC(containsString("form id=\"kc-form-login\"")))
.execute();
setAdapterAndServerTimeOffset(0, employee2ServletPage.toString());
}
}
}

View file

@ -0,0 +1,17 @@
package org.keycloak.testsuite.broker;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.services.resources.RealmsResource;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriBuilderException;
import java.net.URI;
public abstract class AbstractSamlBrokerTest extends AbstractInitializedBaseBrokerTest {
protected URI getAuthServerSamlEndpoint(String realm) throws IllegalArgumentException, UriBuilderException {
return RealmsResource
.protocolUrl(UriBuilder.fromUri(getAuthServerRoot()))
.build(realm, SamlProtocol.LOGIN_PROTOCOL);
}
}

View file

@ -26,6 +26,8 @@ import java.util.List;
import java.util.Map;
import static org.keycloak.broker.saml.SAMLIdentityProviderConfig.*;
import static org.keycloak.protocol.saml.SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlProtocol.SAML_IDP_INITIATED_SSO_URL_NAME;
import static org.keycloak.testsuite.broker.BrokerTestConstants.*;
import static org.keycloak.testsuite.broker.BrokerTestTools.*;
@ -75,7 +77,7 @@ public class KcSamlBrokerConfiguration implements BrokerConfiguration {
attributes.put(SamlConfigAttributes.SAML_AUTHNSTATEMENT, "true");
attributes.put(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE,
getAuthRoot(suiteContext) + "/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_SAML_ALIAS + "/endpoint");
attributes.put(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE,
attributes.put(SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE,
getAuthRoot(suiteContext) + "/auth/realms/" + REALM_CONS_NAME + "/broker/" + IDP_SAML_ALIAS + "/endpoint");
attributes.put(SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true");
attributes.put(SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE, "username");
@ -168,6 +170,8 @@ public class KcSamlBrokerConfiguration implements BrokerConfiguration {
.addRedirectUri("https://localhost:8543/sales-post/*")
.attribute(SamlConfigAttributes.SAML_AUTHNSTATEMENT, SamlProtocol.ATTRIBUTE_TRUE_VALUE)
.attribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, SamlProtocol.ATTRIBUTE_FALSE_VALUE)
.attribute(SAML_IDP_INITIATED_SSO_URL_NAME, "sales-post")
.attribute(SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, "https://localhost:8180/sales-post/saml")
.build(),
ClientBuilder.create()
.id("broker-app")

View file

@ -0,0 +1,69 @@
package org.keycloak.testsuite.broker;
import org.junit.Test;
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
import org.keycloak.dom.saml.v2.assertion.StatementAbstractType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.core.saml.v2.util.XMLTimeUtil;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.SamlClient;
import org.keycloak.testsuite.util.SamlClientBuilder;
import java.util.Set;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertThat;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_SAML_ALIAS;
import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_CONS_NAME;
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_EMAIL;
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_LOGIN;
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_PASSWORD;
public class KcSamlBrokerSessionNotOnOrAfterTest extends AbstractSamlBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {
return KcSamlBrokerConfiguration.INSTANCE;
}
@Test
public void testConsumerIdpInitiatedLoginContainsSessionNotOnOrAfter() throws Exception {
SAMLDocumentHolder samlResponse = new SamlClientBuilder()
.idpInitiatedLogin(getAuthServerSamlEndpoint(REALM_CONS_NAME), "sales-post").build()
// Request login via kc-saml-idp
.login().idp(IDP_SAML_ALIAS).build()
.processSamlResponse(SamlClient.Binding.POST) // AuthnRequest to producer IdP
.targetAttributeSamlRequest()
.build()
// Login in provider realm
.login().user(USER_LOGIN, USER_PASSWORD).build()
// Send the response to the consumer realm
.processSamlResponse(SamlClient.Binding.POST).build()
// Create account in comsumer realm
.updateProfile().username(USER_LOGIN).email(USER_EMAIL).firstName("Firstname").lastName("Lastname").build()
.followOneRedirect()
// Obtain the response sent to the app
.getSamlResponse(SamlClient.Binding.POST);
assertThat(samlResponse.getSamlObject(), Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType resp = (ResponseType) samlResponse.getSamlObject();
Set<StatementAbstractType> statements = resp.getAssertions().get(0).getAssertion().getStatements();
AuthnStatementType authType = statements.stream()
.filter(statement -> statement instanceof AuthnStatementType)
.map(s -> (AuthnStatementType) s)
.findFirst().orElse(null);
assertThat(authType, notNullValue());
assertThat(authType.getSessionNotOnOrAfter(), notNullValue());
assertThat(authType.getSessionNotOnOrAfter(), is(XMLTimeUtil.add(authType.getAuthnInstant(), adminClient.realm(REALM_CONS_NAME).toRepresentation().getSsoSessionMaxLifespan() * 1000)));
}
}

View file

@ -0,0 +1,75 @@
package org.keycloak.testsuite.saml;
import org.junit.Test;
import org.keycloak.dom.saml.v2.SAML2Object;
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
import org.keycloak.dom.saml.v2.assertion.StatementAbstractType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.processing.core.saml.v2.util.XMLTimeUtil;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.SamlClient;
import org.keycloak.testsuite.util.SamlClientBuilder;
import java.util.Set;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertThat;
/**
* @author mhajas
*/
public class SessionNotOnOrAfterTest extends AbstractSamlTest {
private static final Integer SSO_MAX_LIFESPAN = 3602;
private SAML2Object checkSessionNotOnOrAfter(SAML2Object ob) {
assertThat(ob, Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
ResponseType resp = (ResponseType) ob;
Set<StatementAbstractType> statements = resp.getAssertions().get(0).getAssertion().getStatements();
AuthnStatementType authType = statements.stream()
.filter(statement -> statement instanceof AuthnStatementType)
.map(s -> (AuthnStatementType) s)
.findFirst().orElse(null);
assertThat(authType, notNullValue());
assertThat(authType.getSessionNotOnOrAfter(), notNullValue());
assertThat(authType.getSessionNotOnOrAfter(), is(XMLTimeUtil.add(authType.getAuthnInstant(), SSO_MAX_LIFESPAN * 1000)));
return null;
}
@Test
public void testSamlResponseContainsSessionNotOnOrAfterIdpInitiatedLogin() throws Exception {
try(AutoCloseable c = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
.updateWith(r -> r.setSsoSessionMaxLifespan(SSO_MAX_LIFESPAN))
.update()) {
new SamlClientBuilder()
.idpInitiatedLogin(getAuthServerSamlEndpoint(REALM_NAME), "sales-post").build()
.login().user(bburkeUser).build()
.processSamlResponse(SamlClient.Binding.POST)
.transformObject(this::checkSessionNotOnOrAfter)
.build()
.execute();
}
}
@Test
public void testSamlResponseContainsSessionNotOnOrAfterAuthnLogin() throws Exception {
try(AutoCloseable c = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
.updateWith(r -> r.setSsoSessionMaxLifespan(SSO_MAX_LIFESPAN))
.update()) {
new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST, SAML_ASSERTION_CONSUMER_URL_SALES_POST, SamlClient.Binding.POST)
.build()
.login().user(bburkeUser).build()
.processSamlResponse(SamlClient.Binding.POST)
.transformObject(this::checkSessionNotOnOrAfter)
.build()
.execute();
}
}
}