diff --git a/saml-core/src/main/java/org/keycloak/saml/SAML2LoginResponseBuilder.java b/saml-core/src/main/java/org/keycloak/saml/SAML2LoginResponseBuilder.java index c3241c2e66..6be861e392 100755 --- a/saml-core/src/main/java/org/keycloak/saml/SAML2LoginResponseBuilder.java +++ b/saml-core/src/main/java/org/keycloak/saml/SAML2LoginResponseBuilder.java @@ -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()); diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java index e146602906..a45a0d021b 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java @@ -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); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletSessionTimeoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletSessionTimeoutTest.java index 53184b7f09..62293b9d79 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletSessionTimeoutTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletSessionTimeoutTest.java @@ -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 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()); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractSamlBrokerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractSamlBrokerTest.java new file mode 100644 index 0000000000..780f8ca2bd --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/AbstractSamlBrokerTest.java @@ -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); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java index c4af21475c..63ec8828c9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java @@ -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") diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerSessionNotOnOrAfterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerSessionNotOnOrAfterTest.java new file mode 100644 index 0000000000..45d8ace234 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerSessionNotOnOrAfterTest.java @@ -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 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))); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SessionNotOnOrAfterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SessionNotOnOrAfterTest.java new file mode 100644 index 0000000000..ee2ad199b7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/SessionNotOnOrAfterTest.java @@ -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 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(); + } + } +}