KEYCLOAK-10776 Add session expiration to Keycloak saml login response
This commit is contained in:
parent
4b18c6a117
commit
57a8fcb669
7 changed files with 246 additions and 3 deletions
|
@ -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());
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue