From 687dd6e1ca56a1a9894184116d4bed091f7d8e6e Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Fri, 26 Sep 2025 11:49:02 +0200 Subject: [PATCH] Support EntraID's AuthnContext values for multifactor authentication Closes #11721 Signed-off-by: Alexander Schwartz --- .../topics/clients/con-protocol-mappers.adoc | 14 ++ ...atorReferenceTypeToAuthnContextMapper.java | 237 ++++++++++++++++++ .../keycloak/services/messages/Messages.java | 3 + .../org.keycloak.protocol.ProtocolMapper | 1 + .../testsuite/util/SamlClientBuilder.java | 7 + .../testsuite/util/saml/TotpBuilder.java | 115 +++++++++ ...ReferenceTypeToAuthnContextMapperTest.java | 163 ++++++++++++ .../admin/messages/messages_en.properties | 3 + 8 files changed, 543 insertions(+) create mode 100755 services/src/main/java/org/keycloak/protocol/saml/mappers/AuthenticatorReferenceTypeToAuthnContextMapper.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/TotpBuilder.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AuthenticatorReferenceTypeToAuthnContextMapperTest.java diff --git a/docs/documentation/server_admin/topics/clients/con-protocol-mappers.adoc b/docs/documentation/server_admin/topics/clients/con-protocol-mappers.adoc index d091afd98e84..2a1ec515f865 100644 --- a/docs/documentation/server_admin/topics/clients/con-protocol-mappers.adoc +++ b/docs/documentation/server_admin/topics/clients/con-protocol-mappers.adoc @@ -62,6 +62,20 @@ Service account sessions provide the following details: * *clientAddress*: The remote host IP of the service account's authenticated device. * *clientHost*: The remote host name of the service account's authenticated device. +== SAML AuthnContext mapper + +By default, {project_name} will set the AuthnContext with AuthnContextClassRef set to `urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified`. + +To specify a different value based on specific authenticator reference types completed by the user during authentication, add the *Authenticator reference type to AuthnContext mapper*. + +Use this, for example, to forward information about a successful multifactor authentication to Entra ID as shown in the following example: + +Authenticator reference types:: `otp`, `recovery-authn-codes`, `webauthn`, `webauthn-passwordless` + +Authn Context:: `+http://schemas.microsoft.com/claims/multipleauthn+` + +See https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-expected-inbound-assertions[Satisfy Microsoft Entra ID multifactor authentication (MFA) controls with MFA claims from a federated IdP] for additional information. + == Script mapper Use the *Script Mapper* to map claims to tokens by running user-defined JavaScript code. For more details about deploying scripts to the server, see link:{developerguide_jsproviders_link}[{developerguide_jsproviders_name}]. diff --git a/services/src/main/java/org/keycloak/protocol/saml/mappers/AuthenticatorReferenceTypeToAuthnContextMapper.java b/services/src/main/java/org/keycloak/protocol/saml/mappers/AuthenticatorReferenceTypeToAuthnContextMapper.java new file mode 100755 index 000000000000..d7fa749c486c --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/saml/mappers/AuthenticatorReferenceTypeToAuthnContextMapper.java @@ -0,0 +1,237 @@ +/* + * Copyright 2016 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.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.ConfigurableAuthenticatorFactory; +import org.keycloak.authentication.authenticators.util.AuthenticatorUtils; +import org.keycloak.common.util.Time; +import org.keycloak.dom.saml.v2.assertion.AuthnContextClassRefType; +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.models.ClientSessionContext; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.ProtocolMapperContainerModel; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.credential.OTPCredentialModel; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.protocol.ProtocolMapperConfigException; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.utils.StringUtil; + +import org.jboss.logging.Logger; + +import static org.keycloak.provider.ProviderConfigProperty.INTEGER_TYPE; +import static org.keycloak.provider.ProviderConfigProperty.MULTIVALUED_STRING_TYPE; +import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; +import static org.keycloak.services.messages.Messages.INVALID_AUTHENTICATOR_REFERENCE_NONE_VALID; +import static org.keycloak.services.messages.Messages.INVALID_AUTHENTICATOR_REFERENCE_SOME_VALID; + +/** + * Update the authn context in the SAML response based on the authenticators used in the user session. + *

+ * Use it for example to pass on the information to Entra ID if a multifactor authentication was used. + *

+ * It caters for the special needs as described in + * Satisfy Microsoft Entra ID multifactor authentication (MFA) controls with MFA claims from a federated IdP. + *

+ * TL;DR: the following should be provided if multi-factor is used: + *

+ *

+ * {@code
+ * 
+ *     
+ *         http://schemas.microsoft.com/claims/multipleauthn
+ *     
+ * 
+ * }
+ * 
+ * + */ +public class AuthenticatorReferenceTypeToAuthnContextMapper extends AbstractSAMLProtocolMapper implements SAMLLoginResponseMapper { + + private static final Logger LOG = Logger.getLogger(AuthenticatorReferenceTypeToAuthnContextMapper.class); + public static final String PROVIDER_ID = "authenticator-to-authn-context-mapper"; + public static final String SCHEMAS_MICROSOFT_COM_CLAIMS_MULTIPLEAUTHN = "http://schemas.microsoft.com/claims/multipleauthn"; + public static final String CONFIG_AUTHN_CONTEXT = "authnContext"; + public static final String CONFIG_MAX_AGE = "maxAge"; + public static final String CONFIG_AUTHENTICATOR_REFERENCE_TYPES = "authenticatorReferenceTypes"; + private static final List POTENTIAL_DEFAULT_AUTHENTICATOR_REFERENCES = + List.of(OTPCredentialModel.TYPE, WebAuthnCredentialModel.TYPE_TWOFACTOR, WebAuthnCredentialModel.TYPE_PASSWORDLESS, RecoveryAuthnCodesCredentialModel.TYPE); + private List allAuthenticatorReferenceTypes; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Authenticator reference type to AuthnContext"; + } + + @Override + public String getDisplayCategory() { + return AttributeStatementHelper.ATTRIBUTE_STATEMENT_CATEGORY; + } + + @Override + public String getHelpText() { + return "Update the SAML Authn context based on the used Authenticator reference category. For example, when '" + OTPCredentialModel.TYPE + "' is found, " + + "set the Authn Context to the value expected by Entra ID for multi-factor authentication as '" + SCHEMAS_MICROSOFT_COM_CLAIMS_MULTIPLEAUTHN + "'."; + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + allAuthenticatorReferenceTypes = factory.getProviderFactoriesStream(Authenticator.class) + .filter(providerFactory -> (providerFactory instanceof ConfigurableAuthenticatorFactory)) + .map(providerFactory -> ((ConfigurableAuthenticatorFactory) providerFactory).getReferenceCategory()) + .filter(Objects::nonNull) + .distinct() + .sorted() + .toList(); + } + + @Override + public ResponseType transformLoginResponse(ResponseType response, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) { + Set authenticatorReferenceTypes = new HashSet<>(Arrays.asList(Constants.CFG_DELIMITER_PATTERN.split(mappingModel.getConfig().getOrDefault(CONFIG_AUTHENTICATOR_REFERENCE_TYPES, "")))); + String configMaxAge = mappingModel.getConfig().get(CONFIG_MAX_AGE); + Integer maxAge = StringUtil.isBlank(configMaxAge) ? null : Integer.parseInt(configMaxAge); + String authnContext = mappingModel.getConfig().getOrDefault(CONFIG_AUTHN_CONTEXT, SCHEMAS_MICROSOFT_COM_CLAIMS_MULTIPLEAUTHN); + + Map executions = AuthenticatorUtils.parseCompletedExecutions(userSession.getNote(Constants.AUTHENTICATORS_COMPLETED)); + // From the authenticator executions... + List executedReferenceCategories = executions.entrySet().stream() + // ... filter those recently executed + .filter(e -> maxAge == null || e.getValue() + maxAge > Time.currentTime()) + // ... map executions to authenticators + .map(s -> userSession.getRealm().getAuthenticationExecutionById(s.getKey()).getAuthenticator()) + .filter(Objects::nonNull) + // ... map authenticators to reference category + .map(a -> { + ProviderFactory providerFactory = session.getKeycloakSessionFactory().getProviderFactory(Authenticator.class, a); + if (providerFactory instanceof ConfigurableAuthenticatorFactory caf) { + return caf.getReferenceCategory(); + } else { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + List matchingCredentials = executedReferenceCategories.stream().filter(authenticatorReferenceTypes::contains).toList(); + if (!matchingCredentials.isEmpty()) { + LOG.tracef("Found authenticators: %s", matchingCredentials); + for (ResponseType.RTChoiceType assertion : response.getAssertions()) { + for (StatementAbstractType statement : assertion.getAssertion().getStatements()) { + if (statement instanceof AuthnStatementType authnStatement) { + LOG.tracef("Authn Context set to: %s", authnContext); + authnStatement.getAuthnContext().getSequence().setClassRef(new AuthnContextClassRefType(URI.create(authnContext))); + } + } + } + } else { + LOG.debugf("No authenticator %s found in list: %s", authenticatorReferenceTypes, executedReferenceCategories); + } + return response; + } + + @Override + public void validateConfig(KeycloakSession session, RealmModel realm, ProtocolMapperContainerModel client, ProtocolMapperModel mapperModel) throws ProtocolMapperConfigException { + super.validateConfig(session, realm, client, mapperModel); + String configReferenceTypes = mapperModel.getConfig().get(CONFIG_AUTHENTICATOR_REFERENCE_TYPES); + if (configReferenceTypes == null) { + // The frontend doesn't save the value if it is unchanged + // https://github.com/keycloak/keycloak/issues/43949 + configReferenceTypes = getDefaultAuthenticators(); + mapperModel.getConfig().put(CONFIG_AUTHENTICATOR_REFERENCE_TYPES, configReferenceTypes); + } + if (configReferenceTypes != null) { + String[] authenticators = Constants.CFG_DELIMITER_PATTERN.split(configReferenceTypes); + List unknownAuthenticators = Arrays.stream(authenticators) + .filter(a -> !allAuthenticatorReferenceTypes.contains(a)) + .toList(); + if (!unknownAuthenticators.isEmpty()) { + List knownAuthenticators = Arrays.stream(authenticators) + .filter(a -> allAuthenticatorReferenceTypes.contains(a)) + .toList(); + if (!knownAuthenticators.isEmpty()) { + String knownAuthenticatorsString = String.join(", ", knownAuthenticators.stream().map(v -> "'" + v + "'").toList()); + // Avoid to reflect invalid values to the caller as it is a bad security practice to do so. + // Therefore, we're only returning the valid one. + throw new ProtocolMapperConfigException(INVALID_AUTHENTICATOR_REFERENCE_SOME_VALID, "Unknown authenticator reference types found.", knownAuthenticatorsString); + } else { + throw new ProtocolMapperConfigException("Unknown authenticator reference types found.", INVALID_AUTHENTICATOR_REFERENCE_NONE_VALID); + } + } + } + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create() + + .property() + .name(CONFIG_AUTHENTICATOR_REFERENCE_TYPES) + .label("Authenticator reference types") + .helpText("Authenticator reference types where one of them must have been completed by the user. Available authenticator reference types: " + String.join(", ", allAuthenticatorReferenceTypes)) + .type(MULTIVALUED_STRING_TYPE) + .defaultValue(getDefaultAuthenticators()) + .options(new ArrayList<>(allAuthenticatorReferenceTypes)) + .required(true) + .add() + + .property() + .name(CONFIG_MAX_AGE) + .label("Maximum authentication age") + .helpText("Maximum age of the successful authentication for the authenticator reference class. To use this option, update your authentication flow to ask again for the second factor credentials after a specified period,") + .type(INTEGER_TYPE) + .add() + + .property() + .name(CONFIG_AUTHN_CONTEXT) + .label("Authn Context") + .helpText("Value set in the SAML response for the Authn Context. The expected value for Entra ID for multi-factor authentication is '\" + SCHEMAS_MICROSOFT_COM_CLAIMS_MULTIPLEAUTHN + \"'") + .type(STRING_TYPE) + .defaultValue(SCHEMAS_MICROSOFT_COM_CLAIMS_MULTIPLEAUTHN) + .required(true) + .add() + + .build(); + } + + private String getDefaultAuthenticators() { + return String.join(Constants.CFG_DELIMITER, POTENTIAL_DEFAULT_AUTHENTICATOR_REFERENCES.stream().sorted().filter(e -> allAuthenticatorReferenceTypes.stream().anyMatch(e::equals)).toList()); + } +} diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index c3dc8f344428..d1ee097bed4d 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -347,4 +347,7 @@ public class Messages { public static final String CONFIRM_ORGANIZATION_MEMBERSHIP = "organization.confirm-membership"; public static final String CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE = "organization.confirm-membership.title"; public static final String REGISTER_ORGANIZATION_MEMBER = "organization.member.register.title"; + + public static final String INVALID_AUTHENTICATOR_REFERENCE_SOME_VALID = "invalidAuthenticatorReference.someValid"; + public static final String INVALID_AUTHENTICATOR_REFERENCE_NONE_VALID = "invalidAuthenticatorReference.noneValid"; } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index 279fa2b38b14..c7911cb3a4b9 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -44,6 +44,7 @@ org.keycloak.protocol.oidc.mappers.SHA256PairwiseSubMapper org.keycloak.protocol.docker.mapper.AllowAllDockerProtocolMapper org.keycloak.protocol.saml.mappers.SAMLAudienceProtocolMapper org.keycloak.protocol.saml.mappers.SAMLAudienceResolveProtocolMapper +org.keycloak.protocol.saml.mappers.AuthenticatorReferenceTypeToAuthnContextMapper org.keycloak.protocol.oidc.mappers.ClaimsParameterTokenMapper org.keycloak.protocol.saml.mappers.UserAttributeNameIdMapper org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java index 6cae8f0471d3..6ea981fafa75 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java @@ -39,6 +39,7 @@ import org.keycloak.testsuite.util.saml.LoginBuilder; import org.keycloak.testsuite.util.saml.ModifySamlResponseStepBuilder; import org.keycloak.testsuite.util.saml.RequiredConsentBuilder; +import org.keycloak.testsuite.util.saml.TotpBuilder; import org.keycloak.testsuite.util.saml.UpdateProfileBuilder; import org.apache.http.client.methods.CloseableHttpResponse; @@ -206,6 +207,12 @@ public LoginBuilder login() { return addStepBuilder(new LoginBuilder(this)); } + + /** Handles the OTP page */ + public TotpBuilder totp() { + return addStepBuilder(new TotpBuilder(this)); + } + /** Handles update profile page after login */ public UpdateProfileBuilder updateProfile() { return addStepBuilder(new UpdateProfileBuilder(this)); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/TotpBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/TotpBuilder.java new file mode 100644 index 000000000000..3f790103a5c9 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/TotpBuilder.java @@ -0,0 +1,115 @@ +/* + * Copyright 2017 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.util.saml; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.testsuite.util.SamlClient.Step; +import org.keycloak.testsuite.util.SamlClientBuilder; + +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; + +import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +/** + * + * @author hmlnarik + */ +public class TotpBuilder implements Step { + + private final SamlClientBuilder clientBuilder; + private String totpSecret; + + public TotpBuilder(SamlClientBuilder clientBuilder) { + this.clientBuilder = clientBuilder; + } + + @Override + public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception { + assertThat(currentResponse, statusCodeIsHC(Response.Status.OK)); + String loginPageText = EntityUtils.toString(currentResponse.getEntity(), StandardCharsets.UTF_8); + assertThat(loginPageText, containsString("login")); + + return handleOtpPage(loginPageText, currentURI); + } + + public SamlClientBuilder build() { + return this.clientBuilder; + } + + public TotpBuilder secret(String totpSecret) { + this.totpSecret = totpSecret; + return this; + } + + /** + * Prepares a POST request for logging the given user into the given login page. The login page is expected + * to have at least input fields with id "username" and "password". + * + * @param loginPage + * @return + */ + private HttpUriRequest handleOtpPage(String loginPage, URI currentURI) { + return handleOtpPage(totpSecret, loginPage); + } + + public static HttpUriRequest handleOtpPage(String totpSecret, String loginPage) { + + org.jsoup.nodes.Document theLoginPage = Jsoup.parse(loginPage); + + List parameters = new LinkedList<>(); + for (Element form : theLoginPage.getElementsByTag("form")) { + String action = form.attr("action"); + + for (Element input : form.getElementsByTag("input")) { + if (Objects.equals(input.id(), "otp")) { + parameters.add(new BasicNameValuePair(input.attr("name"), new TimeBasedOTP().generateTOTP(totpSecret))); + } + } + + HttpPost res = new HttpPost(action); + + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8); + res.setEntity(formEntity); + + return res; + } + + throw new IllegalArgumentException("Invalid login form: " + loginPage); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AuthenticatorReferenceTypeToAuthnContextMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AuthenticatorReferenceTypeToAuthnContextMapperTest.java new file mode 100644 index 000000000000..f066ec3b1857 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AuthenticatorReferenceTypeToAuthnContextMapperTest.java @@ -0,0 +1,163 @@ +/* + * 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.net.URI; +import java.util.Objects; + +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.dom.saml.v2.assertion.AuthnStatementType; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.models.credential.OTPCredentialModel; +import org.keycloak.protocol.saml.mappers.AuthenticatorReferenceTypeToAuthnContextMapper; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.updaters.ProtocolMappersUpdater; +import org.keycloak.testsuite.util.SamlClient; +import org.keycloak.testsuite.util.SamlClientBuilder; +import org.keycloak.testsuite.util.UserBuilder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.keycloak.testsuite.saml.RoleMapperTest.createSamlProtocolMapper; +import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_PORT; +import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SCHEME; +import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SSL_REQUIRED; + +/** + * + * @author rmartinc + */ +public class AuthenticatorReferenceTypeToAuthnContextMapperTest 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(); + } + + + @Test + public void testNoOtp() throws Exception { + pmu.add(createSamlProtocolMapper(AuthenticatorReferenceTypeToAuthnContextMapper.PROVIDER_ID, + AuthenticatorReferenceTypeToAuthnContextMapper.CONFIG_AUTHENTICATOR_REFERENCE_TYPES, "otp", + AuthenticatorReferenceTypeToAuthnContextMapper.CONFIG_AUTHN_CONTEXT, "target")).update(); + // remove full scope + try (ClientAttributeUpdater cau = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_EMPLOYEE_2) + .setFullScopeAllowed(false) + .update()) { + + 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); + + verifyAuthn(document, JBossSAMLURIConstants.AC_UNSPECIFIED.get()); + + } + } + + @Test + public void testMappingOfAuthnContext() throws Exception { + pmu.add(createSamlProtocolMapper(AuthenticatorReferenceTypeToAuthnContextMapper.PROVIDER_ID, + AuthenticatorReferenceTypeToAuthnContextMapper.CONFIG_AUTHENTICATOR_REFERENCE_TYPES, "otp", + AuthenticatorReferenceTypeToAuthnContextMapper.CONFIG_AUTHN_CONTEXT, "target")).update(); + // remove full scope + try (ClientAttributeUpdater cau = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_EMPLOYEE_2) + .setFullScopeAllowed(false) + .update()) { + + UserRepresentation userRep = UserBuilder.edit(bburkeUser) + .totpSecret("totpSecret") + .otpEnabled().build(); + UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(REALM_NAME), bburkeUser.getUsername()); + user.update(userRep); + + try { + 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() + .totp().secret("totpSecret").build() + .getSamlResponse(SamlClient.Binding.POST); + + verifyAuthn(document, "target"); + } finally { + CredentialRepresentation cr = user.credentials().stream().filter(credentialRepresentation -> credentialRepresentation.getType().equals(OTPCredentialModel.TYPE)).findFirst().orElseThrow(); + user.removeCredential(cr.getId()); + } + } + } + + @Test + public void testExpiredAuthentication() throws Exception { + pmu.add(createSamlProtocolMapper(AuthenticatorReferenceTypeToAuthnContextMapper.PROVIDER_ID, + AuthenticatorReferenceTypeToAuthnContextMapper.CONFIG_AUTHENTICATOR_REFERENCE_TYPES, "otp", + AuthenticatorReferenceTypeToAuthnContextMapper.CONFIG_AUTHN_CONTEXT, "target", + AuthenticatorReferenceTypeToAuthnContextMapper.CONFIG_MAX_AGE, "-1")).update(); + // remove full scope + try (ClientAttributeUpdater cau = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_EMPLOYEE_2) + .setFullScopeAllowed(false) + .update()) { + + UserRepresentation userRep = UserBuilder.edit(bburkeUser) + .totpSecret("totpSecret") + .otpEnabled().build(); + UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(REALM_NAME), bburkeUser.getUsername()); + user.update(userRep); + + try { + 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() + .totp().secret("totpSecret").build() + .getSamlResponse(SamlClient.Binding.POST); + + verifyAuthn(document, JBossSAMLURIConstants.AC_UNSPECIFIED.get()); + } finally { + CredentialRepresentation cr = user.credentials().stream().filter(credentialRepresentation -> credentialRepresentation.getType().equals(OTPCredentialModel.TYPE)).findFirst().orElseThrow(); + user.removeCredential(cr.getId()); + } + } + } + + private static void verifyAuthn(SAMLDocumentHolder document, String uri) { + ((ResponseType) document.getSamlObject()).getAssertions().stream() + .map(ResponseType.RTChoiceType::getAssertion) + .filter(Objects::nonNull) + .flatMap(assertionType -> assertionType.getStatements().stream()) + .filter(statementAbstractType -> statementAbstractType instanceof AuthnStatementType authnStatementType && authnStatementType.getAuthnContext().getSequence().getClassRef().getValue().equals(URI.create(uri))) + .findAny().orElseThrow(() -> new RuntimeException("didn't find authn '" + uri + "'")); + } + +} diff --git a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties index 47cec4eaa256..89759d5b2327 100644 --- a/themes/src/main/resources/theme/base/admin/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/messages_en.properties @@ -75,3 +75,6 @@ client_security-admin-console=Security Admin Console client_admin-cli=Admin CLI client_realm-management=Realm Management client_broker=Broker + +invalidAuthenticatorReference.someValid=Only the following are valid: {0} +invalidAuthenticatorReference.noneValid=None of the values are valid. \ No newline at end of file