KEYCLOAK-12001: Audience support for SAML clients

This commit is contained in:
rmartinc 2019-11-22 12:47:45 +01:00 committed by Hynek Mlnařík
parent d8e450719b
commit 1989483401
7 changed files with 473 additions and 6 deletions

View file

@ -458,7 +458,7 @@ public class SamlProtocol implements LoginProtocol {
assertion.addStatement(attributeStatement);
}
samlModel = transformLoginResponse(loginResponseMappers, samlModel, session, userSession, clientSession);
samlModel = transformLoginResponse(loginResponseMappers, samlModel, session, userSession, clientSessionCtx);
samlDocument = builder.buildDocument(samlModel);
} catch (Exception e) {
logger.error("failed", e);
@ -528,13 +528,14 @@ public class SamlProtocol implements LoginProtocol {
return attributeStatement;
}
public ResponseType transformLoginResponse(List<ProtocolMapperProcessor<SAMLLoginResponseMapper>> mappers, ResponseType response, KeycloakSession session, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
public ResponseType transformLoginResponse(List<ProtocolMapperProcessor<SAMLLoginResponseMapper>> mappers, ResponseType response,
KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
for (ProtocolMapperProcessor<SAMLLoginResponseMapper> processor : mappers) {
response = processor.mapper.transformLoginResponse(response, processor.model, session, userSession, clientSession);
response = processor.mapper.transformLoginResponse(response, processor.model, session, userSession, clientSessionCtx);
}
for (Iterator<SamlAuthenticationPreprocessor> it = SamlSessionUtils.getSamlAuthenticationPreprocessorIterator(session); it.hasNext(); ) {
response = (ResponseType) it.next().beforeSendingResponse(response, clientSession);
response = (ResponseType) it.next().beforeSendingResponse(response, clientSessionCtx.getClientSession());
}
return response;

View file

@ -0,0 +1,136 @@
/*
* Copyright 2019 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed 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.keycloak.protocol.saml.mappers;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.dom.saml.v2.assertion.AudienceRestrictionType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.provider.ProviderConfigProperty;
/**
* SAML mapper to add a audience restriction into the assertion, to another
* client (clientId) or to a custom URI. Only one URI is added, clientId
* has preference over the custom value (the class maps OIDC behavior).
*
* @author rmartinc
*/
public class SAMLAudienceProtocolMapper extends AbstractSAMLProtocolMapper implements SAMLLoginResponseMapper {
protected static final Logger logger = Logger.getLogger(SAMLAudienceProtocolMapper.class);
public static final String PROVIDER_ID = "saml-audience-mapper";
public static final String AUDIENCE_CATEGORY = "Audience mapper";
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
public static final String INCLUDED_CLIENT_AUDIENCE = "included.client.audience";
private static final String INCLUDED_CLIENT_AUDIENCE_LABEL = "included.client.audience.label";
private static final String INCLUDED_CLIENT_AUDIENCE_HELP_TEXT = "included.client.audience.tooltip";
public static final String INCLUDED_CUSTOM_AUDIENCE = "included.custom.audience";
private static final String INCLUDED_CUSTOM_AUDIENCE_LABEL = "included.custom.audience.label";
private static final String INCLUDED_CUSTOM_AUDIENCE_HELP_TEXT = "included.custom.audience.tooltip";
static {
ProviderConfigProperty property;
property = new ProviderConfigProperty();
property.setName(INCLUDED_CLIENT_AUDIENCE);
property.setLabel(INCLUDED_CLIENT_AUDIENCE_LABEL);
property.setHelpText(INCLUDED_CLIENT_AUDIENCE_HELP_TEXT);
property.setType(ProviderConfigProperty.CLIENT_LIST_TYPE);
configProperties.add(property);
property = new ProviderConfigProperty();
property.setName(INCLUDED_CUSTOM_AUDIENCE);
property.setLabel(INCLUDED_CUSTOM_AUDIENCE_LABEL);
property.setHelpText(INCLUDED_CUSTOM_AUDIENCE_HELP_TEXT);
property.setType(ProviderConfigProperty.STRING_TYPE);
configProperties.add(property);
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Audience";
}
@Override
public String getDisplayCategory() {
return AUDIENCE_CATEGORY;
}
@Override
public String getHelpText() {
return "Add specified audience to the audience conditions in the assertion.";
}
protected static AudienceRestrictionType locateAudienceRestriction(ResponseType response) {
try {
return response.getAssertions().get(0).getAssertion().getConditions().getConditions()
.stream()
.filter(AudienceRestrictionType.class::isInstance)
.map(AudienceRestrictionType.class::cast)
.findFirst().orElse(null);
} catch (NullPointerException | IndexOutOfBoundsException e) {
logger.warn("Invalid SAML ResponseType to add the audience restriction", e);
return null;
}
}
@Override
public ResponseType transformLoginResponse(ResponseType response,
ProtocolMapperModel mappingModel, KeycloakSession session,
UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
// read configuration as in OIDC (first clientId, then custom)
String audience = mappingModel.getConfig().get(INCLUDED_CLIENT_AUDIENCE);
if (audience == null || audience.isEmpty()) {
audience = mappingModel.getConfig().get(INCLUDED_CUSTOM_AUDIENCE);
}
// locate the first condition that has an audience restriction
if (audience != null && !audience.isEmpty()) {
AudienceRestrictionType aud = locateAudienceRestriction(response);
if (aud != null) {
logger.debugf("adding audience: %s", audience);
try {
aud.addAudience(URI.create(audience));
} catch (IllegalArgumentException e) {
logger.warnf(e, "Invalid URI syntax for audience: %s", audience);
}
}
}
return response;
}
}

View file

@ -0,0 +1,111 @@
/*
* Copyright 2019 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed 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.keycloak.protocol.saml.mappers;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.jboss.logging.Logger;
import org.keycloak.dom.saml.v2.assertion.AudienceRestrictionType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.provider.ProviderConfigProperty;
/**
* SAML audience resolve mapper. The mapper adds all client_ids of \"allowed\"
* clients to the audience conditions in the assertion. Allowed client means
* any SAML client for which user has at least one client role.
*
* @author rmartinc
*/
public class SAMLAudienceResolveProtocolMapper extends AbstractSAMLProtocolMapper implements SAMLLoginResponseMapper {
protected static final Logger logger = Logger.getLogger(SAMLAudienceResolveProtocolMapper.class);
public static final String PROVIDER_ID = "saml-audience-resolve-mapper";
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Audience Resolve";
}
@Override
public String getDisplayCategory() {
return SAMLAudienceProtocolMapper.AUDIENCE_CATEGORY;
}
@Override
public String getHelpText() {
return "Adds all client_ids of \"allowed\" clients to the audience conditions in the assertion. " +
"Allowed client means any SAML client for which user has at least one client role";
}
@Override
public ResponseType transformLoginResponse(ResponseType response,
ProtocolMapperModel mappingModel, KeycloakSession session,
UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
// get the audience restriction
AudienceRestrictionType aud = SAMLAudienceProtocolMapper.locateAudienceRestriction(response);
if (aud != null) {
// get all the roles the user has and calculate the clientIds to add
Set<RoleModel> roles = clientSessionCtx.getRoles();
Set<String> audiences = new HashSet<>();
// add as audience any SAML clientId with role included (same as OIDC)
for (RoleModel role : roles) {
logger.tracef("Managing role: %s", role.getName());
if (role.isClientRole()) {
ClientModel app = (ClientModel) role.getContainer();
// only adding SAML clients that are not this clientId (which is added by default)
if (SamlProtocol.LOGIN_PROTOCOL.equals(app.getProtocol()) &&
!app.getClientId().equals(clientSessionCtx.getClientSession().getClient().getClientId())) {
audiences.add(app.getClientId());
}
}
}
logger.debugf("Calculated audiences to add: %s", audiences);
// add the audiences
for (String audience : audiences) {
try {
aud.addAudience(URI.create(audience));
} catch (IllegalArgumentException e) {
logger.warnf(e, "Invalid URI syntax for audience: %s", audience);
}
}
}
return response;
}
}

View file

@ -18,7 +18,7 @@
package org.keycloak.protocol.saml.mappers;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
@ -29,6 +29,7 @@ import org.keycloak.models.UserSessionModel;
*/
public interface SAMLLoginResponseMapper {
ResponseType transformLoginResponse(ResponseType response, ProtocolMapperModel mappingModel, KeycloakSession session,
UserSessionModel userSession, AuthenticatedClientSessionModel clientSession);
UserSessionModel userSession, ClientSessionContext clientSessionCtx);
}

View file

@ -41,3 +41,5 @@ org.keycloak.protocol.oidc.mappers.UserRealmRoleMappingMapper
org.keycloak.protocol.oidc.mappers.SHA256PairwiseSubMapper
org.keycloak.protocol.docker.mapper.AllowAllDockerProtocolMapper
org.keycloak.protocol.oidc.mappers.ScriptBasedOIDCProtocolMapper
org.keycloak.protocol.saml.mappers.SAMLAudienceProtocolMapper
org.keycloak.protocol.saml.mappers.SAMLAudienceResolveProtocolMapper

View file

@ -125,4 +125,9 @@ public class ClientAttributeUpdater extends ServerResourceUpdater<ClientAttribut
rep.setAdminUrl(adminUrl);
return this;
}
public ClientAttributeUpdater addDefaultClientScope(String clientScope) {
rep.getDefaultClientScopes().add(clientScope);
return this;
}
}

View file

@ -0,0 +1,211 @@
/*
* Copyright 2019 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed 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.keycloak.testsuite.saml;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import javax.ws.rs.core.Response;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.greaterThan;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.dom.saml.v2.assertion.AudienceRestrictionType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.protocol.saml.mappers.SAMLAudienceProtocolMapper;
import org.keycloak.protocol.saml.mappers.SAMLAudienceResolveProtocolMapper;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.testsuite.admin.ApiUtil;
import static org.keycloak.testsuite.saml.AbstractSamlTest.REALM_NAME;
import static org.keycloak.testsuite.saml.AbstractSamlTest.SAML_CLIENT_ID_EMPLOYEE_2;
import static org.keycloak.testsuite.saml.RoleMapperTest.createSamlProtocolMapper;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import org.keycloak.testsuite.updaters.ProtocolMappersUpdater;
import org.keycloak.testsuite.updaters.RoleScopeUpdater;
import org.keycloak.testsuite.updaters.UserAttributeUpdater;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.SamlClient;
import org.keycloak.testsuite.util.SamlClientBuilder;
/**
*
* @author rmartinc
*/
public class AudienceProtocolMappersTest extends AbstractSamlTest {
public static final String SAML_ASSERTION_CONSUMER_URL_EMPLOYEE_2 = AUTH_SERVER_SCHEME + "://localhost:" + (AUTH_SERVER_SSL_REQUIRED ? AUTH_SERVER_PORT : 8080) + "/employee2/";
private ProtocolMappersUpdater pmu;
@Before
public void cleanMappersAndScopes() {
this.pmu = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_EMPLOYEE_2).protocolMappers()
.clear()
.update();
}
@After
public void revertCleanMappersAndScopes() throws IOException {
this.pmu.close();
}
public void testExpectedAudiences(String... audiences) {
SAMLDocumentHolder document = new SamlClientBuilder()
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_EMPLOYEE_2, SAML_ASSERTION_CONSUMER_URL_EMPLOYEE_2, SamlClient.Binding.POST).build()
.login().user(bburkeUser).build()
.getSamlResponse(SamlClient.Binding.POST);
Assert.assertNotNull(document.getSamlObject());
Assert.assertThat(document.getSamlObject(), Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
Assert.assertNotNull(((ResponseType) document.getSamlObject()).getAssertions());
Assert.assertThat(((ResponseType) document.getSamlObject()).getAssertions().size(), greaterThan(0));
Assert.assertNotNull(((ResponseType) document.getSamlObject()).getAssertions().get(0));
Assert.assertNotNull(((ResponseType) document.getSamlObject()).getAssertions().get(0).getAssertion());
AudienceRestrictionType audience = ((ResponseType) document.getSamlObject())
.getAssertions().get(0).getAssertion().getConditions().getConditions()
.stream()
.filter(AudienceRestrictionType.class::isInstance)
.map(AudienceRestrictionType.class::cast)
.findFirst().orElse(null);
Assert.assertNotNull(audience);
Assert.assertNotNull(audience.getAudience());
List<String> values = audience.getAudience().stream().map(uri -> uri.toString()).collect(Collectors.toList());
Assert.assertThat(values, containsInAnyOrder(audiences));
}
@Test
public void testDefaultAudience() throws Exception {
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2);
}
@Test
public void testCustomAudience() throws Exception {
pmu.add(
createSamlProtocolMapper(SAMLAudienceProtocolMapper.PROVIDER_ID,
SAMLAudienceProtocolMapper.INCLUDED_CUSTOM_AUDIENCE, "https://test.com/test"
)
).update();
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2, "https://test.com/test");
}
@Test
public void testClientAudience() throws Exception {
pmu.add(
createSamlProtocolMapper(SAMLAudienceProtocolMapper.PROVIDER_ID,
SAMLAudienceProtocolMapper.INCLUDED_CLIENT_AUDIENCE, SAML_CLIENT_ID_SALES_POST
)
).update();
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2, SAML_CLIENT_ID_SALES_POST);
}
@Test
public void testClientAndCustomAudience() throws Exception {
pmu.add(
createSamlProtocolMapper(SAMLAudienceProtocolMapper.PROVIDER_ID,
SAMLAudienceProtocolMapper.INCLUDED_CLIENT_AUDIENCE, SAML_CLIENT_ID_SALES_POST,
SAMLAudienceProtocolMapper.INCLUDED_CUSTOM_AUDIENCE, "https://test.com/test"
)
).update();
// only client is expected because it works as the OIDC one (same labels used)
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2, SAML_CLIENT_ID_SALES_POST);
}
@Test
public void testAudienceResolveFullScope() throws Exception {
pmu.add(createSamlProtocolMapper(SAMLAudienceResolveProtocolMapper.PROVIDER_ID)).update();
// bburke in the saml realm belongs to three different SAML clients groups
// "http://localhost:8280/employee/": [ "employee" ],
// "http://localhost:8280/employee2/": [ "empl.oyee", "employee" ],
// "http://localhost:8280/employee-role-mapping/": ["employee"]
// this way it should contain the three apps by default
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2, "http://localhost:8280/employee/", "http://localhost:8280/employee-role-mapping/");
// remove one of the groups (employee) and check the employee audience is removed
String employeeId = adminClient.realm(REALM_NAME).clients().findByClientId("http://localhost:8280/employee/").get(0).getId();
Assert.assertNotNull(employeeId);
try (RoleScopeUpdater rsc = UserAttributeUpdater.forUserByUsername(adminClient, REALM_NAME, bburkeUser.getUsername())
.clientRoleScope(employeeId)
.removeByName("employee")
.update()) {
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2, "http://localhost:8280/employee-role-mapping/");
}
}
@Test
public void testAudienceResolveNoFullScope() throws Exception {
pmu.add(createSamlProtocolMapper(SAMLAudienceResolveProtocolMapper.PROVIDER_ID)).update();
// remove full scope
try (ClientAttributeUpdater cau = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_EMPLOYEE_2)
.setFullScopeAllowed(false)
.update()) {
// now only the same client should be in the audience
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2);
// add another client in the scope
String employee2Id = adminClient.realm(REALM_NAME).clients().findByClientId("http://localhost:8280/employee2/").get(0).getId();
Assert.assertNotNull(employee2Id);
String employeeId = adminClient.realm(REALM_NAME).clients().findByClientId("http://localhost:8280/employee/").get(0).getId();
Assert.assertNotNull(employeeId);
List<RoleRepresentation> availables = adminClient.realm(REALM_NAME).clients().get(employee2Id).getScopeMappings().clientLevel(employeeId).listAvailable();
Assert.assertThat(availables.size(), greaterThan(0));
// assign scope to only employee2 (employee-role-mapping should not be there)
try (RoleScopeUpdater ru = cau.clientRoleScope(employeeId)
.add(availables.get(0))
.update()) {
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2, "http://localhost:8280/employee/");
}
}
}
@Test
public void testAudienceResolveNoFullScopeClientScopes() throws Exception {
// create the mapper using a client scope
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName("audience-mapper-test-client-scope");
clientScope.setProtocol("saml");
clientScope.setProtocolMappers(Collections.singletonList(createSamlProtocolMapper(SAMLAudienceResolveProtocolMapper.PROVIDER_ID)));
Response res = adminClient.realm(REALM_NAME).clientScopes().create(clientScope);
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), res.getStatus());
String clientScopeId = ApiUtil.getCreatedId(res);
try {
// add a mapping to the client scope to employee2.employee role (this way employee should be in the audience)
String employee2Id = adminClient.realm(REALM_NAME).clients().findByClientId("http://localhost:8280/employee2/").get(0).getId();
Assert.assertNotNull(employee2Id);
String employeeId = adminClient.realm(REALM_NAME).clients().findByClientId("http://localhost:8280/employee/").get(0).getId();
Assert.assertNotNull(employeeId);
List<RoleRepresentation> availables = adminClient.realm(REALM_NAME).clientScopes().get(clientScopeId).getScopeMappings().clientLevel(employeeId).listAvailable();
Assert.assertThat(availables.size(), greaterThan(0));
adminClient.realm(REALM_NAME).clientScopes().get(clientScopeId).getScopeMappings().clientLevel(employeeId).add(availables);
// remove full scope and add the client scope
try (ClientAttributeUpdater cau = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_EMPLOYEE_2)
.setFullScopeAllowed(false)
.addDefaultClientScope("audience-mapper-test-client-scope")
.update()) {
this.testExpectedAudiences(SAML_CLIENT_ID_EMPLOYEE_2, "http://localhost:8280/employee/");
}
} finally {
adminClient.realm(REALM_NAME).clientScopes().get(clientScopeId).remove();
}
}
}