KEYCLOAK-12000: Allow overriding time lifespans on a SAML client
This commit is contained in:
parent
301e76c0b9
commit
82ef5b7927
8 changed files with 145 additions and 13 deletions
|
@ -17,6 +17,7 @@
|
|||
|
||||
package org.keycloak.protocol.saml;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.ClientConfigResolver;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.saml.SignatureAlgorithm;
|
||||
|
@ -31,6 +32,8 @@ import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer;
|
|||
*/
|
||||
public class SamlClient extends ClientConfigResolver {
|
||||
|
||||
protected static final Logger logger = Logger.getLogger(SamlClient.class);
|
||||
|
||||
public static final XmlKeyInfoKeyNameTransformer DEFAULT_XML_KEY_INFO_KEY_NAME_TRANSFORMER = XmlKeyInfoKeyNameTransformer.KEY_ID;
|
||||
|
||||
public SamlClient(ClientModel client) {
|
||||
|
@ -231,5 +234,20 @@ public class SamlClient extends ClientConfigResolver {
|
|||
client.setAttribute(SamlConfigAttributes.SAML_ONETIMEUSE_CONDITION, Boolean.toString(val));
|
||||
}
|
||||
|
||||
public void setAssertionLifespan(int assertionLifespan) {
|
||||
client.setAttribute(SamlConfigAttributes.SAML_ASSERTION_LIFESPAN, Integer.toString(assertionLifespan));
|
||||
}
|
||||
|
||||
public int getAssertionLifespan() {
|
||||
String value = client.getAttribute(SamlConfigAttributes.SAML_ASSERTION_LIFESPAN);
|
||||
if (value == null || value.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(value);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warnf("Invalid numeric value for saml attribute \"%s\": %s", SamlConfigAttributes.SAML_ASSERTION_LIFESPAN, value);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,4 +41,5 @@ public interface SamlConfigAttributes {
|
|||
String SAML_SIGNING_CERTIFICATE_ATTRIBUTE = "saml.signing." + CertificateInfoHelper.X509CERTIFICATE;
|
||||
String SAML_ENCRYPTION_CERTIFICATE_ATTRIBUTE = "saml.encryption." + CertificateInfoHelper.X509CERTIFICATE;
|
||||
String SAML_ENCRYPTION_PRIVATE_KEY_ATTRIBUTE = "saml.encryption." + CertificateInfoHelper.PRIVATE_KEY;
|
||||
String SAML_ASSERTION_LIFESPAN = "saml.assertion.lifespan";
|
||||
}
|
||||
|
|
|
@ -397,12 +397,13 @@ public class SamlProtocol implements LoginProtocol {
|
|||
clientSession.setNote(SAML_NAME_ID, nameId);
|
||||
clientSession.setNote(SAML_NAME_ID_FORMAT, nameIdFormat);
|
||||
|
||||
int assertionLifespan = samlClient.getAssertionLifespan();
|
||||
SAML2LoginResponseBuilder builder = new SAML2LoginResponseBuilder();
|
||||
builder.requestID(requestID)
|
||||
.destination(redirectUri)
|
||||
.issuer(responseIssuer)
|
||||
.assertionExpiration(realm.getAccessCodeLifespan())
|
||||
.subjectExpiration(realm.getAccessTokenLifespan())
|
||||
.assertionExpiration(assertionLifespan <= 0? realm.getAccessCodeLifespan() : assertionLifespan)
|
||||
.subjectExpiration(assertionLifespan <= 0? realm.getAccessTokenLifespan() : assertionLifespan)
|
||||
.sessionExpiration(realm.getSsoSessionMaxLifespan())
|
||||
.requestIssuer(clientSession.getClient().getClientId())
|
||||
.nameIdentifier(nameIdFormat, nameId)
|
||||
|
|
|
@ -68,6 +68,9 @@ public class ClientAttributeUpdater extends ServerResourceUpdater<ClientAttribut
|
|||
|
||||
public ClientAttributeUpdater setAttribute(String name, String value) {
|
||||
this.rep.getAttributes().put(name, value);
|
||||
if (value != null && !this.origRep.getAttributes().containsKey(name)) {
|
||||
this.origRep.getAttributes().put(name, null);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.keycloak.testsuite.saml;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.dom.saml.v2.SAML2Object;
|
||||
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
|
||||
|
@ -14,22 +15,40 @@ import org.keycloak.testsuite.util.SamlClientBuilder;
|
|||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.hamcrest.Matchers.greaterThan;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
import org.junit.Assert;
|
||||
import static org.junit.Assert.assertThat;
|
||||
import org.keycloak.dom.saml.v2.assertion.ConditionsType;
|
||||
import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationDataType;
|
||||
import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationType;
|
||||
import org.keycloak.protocol.saml.SamlConfigAttributes;
|
||||
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
|
||||
|
||||
/**
|
||||
* @author mhajas
|
||||
*/
|
||||
public class SessionNotOnOrAfterTest extends AbstractSamlTest {
|
||||
|
||||
private static final Integer SSO_MAX_LIFESPAN = 3602;
|
||||
private static final int SSO_MAX_LIFESPAN = 3602;
|
||||
private static final int ACCESS_CODE_LIFESPAN = 600;
|
||||
private static final int ACCESS_TOKEN_LIFESPAN = 1200;
|
||||
|
||||
private SAML2Object checkSessionNotOnOrAfter(SAML2Object ob) {
|
||||
private SAML2Object checkSessionNotOnOrAfter(SAML2Object ob, int ssoMaxLifespan,
|
||||
int accessCodeLifespan, int accessTokenLifespan) {
|
||||
assertThat(ob, Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
|
||||
ResponseType resp = (ResponseType) ob;
|
||||
Set<StatementAbstractType> statements = resp.getAssertions().get(0).getAssertion().getStatements();
|
||||
|
||||
Assert.assertNotNull(resp);
|
||||
Assert.assertNotNull(resp.getAssertions());
|
||||
Assert.assertThat(resp.getAssertions().size(), greaterThan(0));
|
||||
Assert.assertNotNull(resp.getAssertions().get(0));
|
||||
Assert.assertNotNull(resp.getAssertions().get(0).getAssertion());
|
||||
|
||||
// session lifespan
|
||||
Assert.assertNotNull(resp.getAssertions().get(0).getAssertion().getStatements());
|
||||
Set<StatementAbstractType> statements = resp.getAssertions().get(0).getAssertion().getStatements();
|
||||
AuthnStatementType authType = statements.stream()
|
||||
.filter(statement -> statement instanceof AuthnStatementType)
|
||||
.map(s -> (AuthnStatementType) s)
|
||||
|
@ -37,7 +56,28 @@ public class SessionNotOnOrAfterTest extends AbstractSamlTest {
|
|||
|
||||
assertThat(authType, notNullValue());
|
||||
assertThat(authType.getSessionNotOnOrAfter(), notNullValue());
|
||||
assertThat(authType.getSessionNotOnOrAfter(), is(XMLTimeUtil.add(authType.getAuthnInstant(), SSO_MAX_LIFESPAN * 1000)));
|
||||
assertThat(authType.getSessionNotOnOrAfter(), is(XMLTimeUtil.add(authType.getAuthnInstant(), ssoMaxLifespan * 1000)));
|
||||
|
||||
// Conditions
|
||||
Assert.assertNotNull(resp.getAssertions().get(0).getAssertion().getConditions());
|
||||
Assert.assertNotNull(resp.getAssertions().get(0).getAssertion().getConditions());
|
||||
ConditionsType condition = resp.getAssertions().get(0).getAssertion().getConditions();
|
||||
|
||||
Assert.assertEquals(XMLTimeUtil.add(condition.getNotBefore(), accessCodeLifespan * 1000), condition.getNotOnOrAfter());
|
||||
|
||||
// SubjectConfirmation (confirmationData has no NotBefore, using the previous one because it's the same)
|
||||
Assert.assertNotNull(resp.getAssertions().get(0).getAssertion().getSubject());
|
||||
Assert.assertNotNull(resp.getAssertions().get(0).getAssertion().getSubject().getConfirmation());
|
||||
List<SubjectConfirmationType> confirmations = resp.getAssertions().get(0).getAssertion().getSubject().getConfirmation();
|
||||
|
||||
SubjectConfirmationDataType confirmationData = confirmations.stream()
|
||||
.map(c -> c.getSubjectConfirmationData())
|
||||
.filter(c -> c != null)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
Assert.assertNotNull(confirmationData);
|
||||
Assert.assertEquals(XMLTimeUtil.add(condition.getNotBefore(), accessTokenLifespan * 1000), confirmationData.getNotOnOrAfter());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@ -45,13 +85,17 @@ public class SessionNotOnOrAfterTest extends AbstractSamlTest {
|
|||
@Test
|
||||
public void testSamlResponseContainsSessionNotOnOrAfterIdpInitiatedLogin() throws Exception {
|
||||
try(AutoCloseable c = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
|
||||
.updateWith(r -> r.setSsoSessionMaxLifespan(SSO_MAX_LIFESPAN))
|
||||
.updateWith(r -> {
|
||||
r.setSsoSessionMaxLifespan(SSO_MAX_LIFESPAN);
|
||||
r.setAccessCodeLifespan(ACCESS_CODE_LIFESPAN);
|
||||
r.setAccessTokenLifespan(ACCESS_TOKEN_LIFESPAN);
|
||||
})
|
||||
.update()) {
|
||||
new SamlClientBuilder()
|
||||
.idpInitiatedLogin(getAuthServerSamlEndpoint(REALM_NAME), "sales-post").build()
|
||||
.login().user(bburkeUser).build()
|
||||
.processSamlResponse(SamlClient.Binding.POST)
|
||||
.transformObject(this::checkSessionNotOnOrAfter)
|
||||
.transformObject(r -> checkSessionNotOnOrAfter(r, SSO_MAX_LIFESPAN, ACCESS_CODE_LIFESPAN, ACCESS_TOKEN_LIFESPAN))
|
||||
.build()
|
||||
.execute();
|
||||
}
|
||||
|
@ -60,14 +104,51 @@ public class SessionNotOnOrAfterTest extends AbstractSamlTest {
|
|||
@Test
|
||||
public void testSamlResponseContainsSessionNotOnOrAfterAuthnLogin() throws Exception {
|
||||
try(AutoCloseable c = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
|
||||
.updateWith(r -> r.setSsoSessionMaxLifespan(SSO_MAX_LIFESPAN))
|
||||
.updateWith(r -> {
|
||||
r.setSsoSessionMaxLifespan(SSO_MAX_LIFESPAN);
|
||||
r.setAccessCodeLifespan(ACCESS_CODE_LIFESPAN);
|
||||
r.setAccessTokenLifespan(ACCESS_TOKEN_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)
|
||||
.transformObject(r -> checkSessionNotOnOrAfter(r, SSO_MAX_LIFESPAN, ACCESS_CODE_LIFESPAN, ACCESS_TOKEN_LIFESPAN))
|
||||
.build()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSamlResponseClientConfigurationIdpInitiatedLogin() throws Exception {
|
||||
int ssoMaxLifespan = adminClient.realm(REALM_NAME).toRepresentation().getSsoSessionMaxLifespan();
|
||||
try(AutoCloseable c = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST)
|
||||
.setAttribute(SamlConfigAttributes.SAML_ASSERTION_LIFESPAN, "2000")
|
||||
.update()) {
|
||||
new SamlClientBuilder()
|
||||
.idpInitiatedLogin(getAuthServerSamlEndpoint(REALM_NAME), "sales-post").build()
|
||||
.login().user(bburkeUser).build()
|
||||
.processSamlResponse(SamlClient.Binding.POST)
|
||||
.transformObject(r -> checkSessionNotOnOrAfter(r, ssoMaxLifespan, 2000, 2000))
|
||||
.build()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSamlResponseClientConfigurationAfterAuthnLogin() throws Exception {
|
||||
int ssoMaxLifespan = adminClient.realm(REALM_NAME).toRepresentation().getSsoSessionMaxLifespan();
|
||||
try(AutoCloseable c = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST)
|
||||
.setAttribute(SamlConfigAttributes.SAML_ASSERTION_LIFESPAN, "1800")
|
||||
.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(r -> checkSessionNotOnOrAfter(r, ssoMaxLifespan, 1800, 1800))
|
||||
.build()
|
||||
.execute();
|
||||
}
|
||||
|
|
|
@ -137,6 +137,8 @@ action-token-generated-by-admin-lifespan=Default Admin-Initiated Action Lifespan
|
|||
action-token-generated-by-admin-lifespan.tooltip=Maximum time before an action permit sent to a user by administrator is expired. This value is recommended to be long to allow administrators send e-mails for users that are currently offline. The default timeout can be overridden immediately before issuing the token.
|
||||
action-token-generated-by-user-lifespan=User-Initiated Action Lifespan
|
||||
action-token-generated-by-user-lifespan.tooltip=Maximum time before an action permit sent by a user (such as a forgot password e-mail) is expired. This value is recommended to be short because it is expected that the user would react to self-created action quickly.
|
||||
saml-assertion-lifespan=Assertion Lifespan
|
||||
saml-assertion-lifespan.tooltip=Lifespan set in the SAML assertion conditions. After that time the assertion will be invalid. The "SessionNotOnOrAfter" attribute is not modified and continue using the "SSO Session Max" time defined at realm level.
|
||||
|
||||
action-token-generated-by-user.execute-actions=Execute Actions
|
||||
action-token-generated-by-user.idp-verify-account-via-email=IdP Account E-mail Verification
|
||||
|
|
|
@ -1037,6 +1037,7 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
|||
$scope.tlsClientCertificateBoundAccessTokens = false;
|
||||
|
||||
$scope.accessTokenLifespan = TimeUnit2.asUnit(client.attributes['access.token.lifespan']);
|
||||
$scope.samlAssertionLifespan = TimeUnit2.asUnit(client.attributes['saml.assertion.lifespan']);
|
||||
|
||||
if(client.origin) {
|
||||
if ($scope.access.viewRealm) {
|
||||
|
@ -1362,6 +1363,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
|||
}
|
||||
}
|
||||
|
||||
$scope.updateAssertionLifespan = function() {
|
||||
if ($scope.samlAssertionLifespan.time) {
|
||||
$scope.clientEdit.attributes['saml.assertion.lifespan'] = $scope.samlAssertionLifespan.toSeconds();
|
||||
} else {
|
||||
$scope.clientEdit.attributes['saml.assertion.lifespan'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
function configureAuthorizationServices() {
|
||||
if ($scope.clientEdit.authorizationServicesEnabled) {
|
||||
if ($scope.accessType == 'public') {
|
||||
|
|
|
@ -503,10 +503,10 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset data-ng-show="protocol == 'openid-connect'">
|
||||
<fieldset>
|
||||
<legend collapsed><span class="text">{{:: 'advanced-client-settings' | translate}}</span> <kc-tooltip>{{:: 'advanced-client-settings.tooltip' | translate}}</kc-tooltip></legend>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group" data-ng-show="protocol == 'openid-connect'">
|
||||
<label class="col-md-2 control-label" for="accessTokenLifespan">{{:: 'access-token-lifespan' | translate}}</label>
|
||||
|
||||
<div class="col-md-6 time-selector">
|
||||
|
@ -523,6 +523,23 @@
|
|||
<kc-tooltip>{{:: 'access-token-lifespan.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-ng-show="protocol == 'saml'">
|
||||
<label class="col-md-2 control-label" for="samlAssertionLifespan">{{:: 'saml-assertion-lifespan' | translate}}</label>
|
||||
|
||||
<div class="col-md-6 time-selector">
|
||||
<input class="form-control" type="number" min="1"
|
||||
max="31536000" data-ng-model="samlAssertionLifespan.time"
|
||||
id="samlAssertionLifespan" name="samlAssertionLifespan"
|
||||
data-ng-change="updateAssertionLifespan()"/>
|
||||
<select class="form-control" name="samlAssertionLifespanUnit" data-ng-model="samlAssertionLifespan.unit" data-ng-change="updateAssertionLifespan()">
|
||||
<option value="Minutes">{{:: 'minutes' | translate}}</option>
|
||||
<option value="Hours">{{:: 'hours' | translate}}</option>
|
||||
<option value="Days">{{:: 'days' | translate}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'saml-assertion-lifespan.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
|
||||
<label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
|
@ -581,4 +598,4 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<kc-menu></kc-menu>
|
||||
<kc-menu></kc-menu>
|
||||
|
|
Loading…
Reference in a new issue