diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 93c4a53703..df073bc6b5 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -148,6 +148,8 @@ public interface OAuth2Constants { // https://www.rfc-editor.org/rfc/rfc9207.html String ISSUER = "iss"; + + String AUTHENTICATOR_METHOD_REFERENCE = "amr"; } diff --git a/docs/documentation/server_admin/images/config-authenticator-reference.png b/docs/documentation/server_admin/images/config-authenticator-reference.png new file mode 100644 index 0000000000..347c45974e Binary files /dev/null and b/docs/documentation/server_admin/images/config-authenticator-reference.png differ diff --git a/docs/documentation/server_admin/topics/authentication/flows.adoc b/docs/documentation/server_admin/topics/authentication/flows.adoc index ad76c6ab45..a51c6b5d77 100644 --- a/docs/documentation/server_admin/topics/authentication/flows.adoc +++ b/docs/documentation/server_admin/topics/authentication/flows.adoc @@ -111,6 +111,12 @@ Executions have a wide variety of actions, from sending a reset email to validat .Adding an authentication execution image:images/Create-authentication-execution.png[Adding an Authentication Execution] +Authentication executions can optionally have a reference value configured. This can be utilized by the _Authentication Method Reference (AMR)_ protocol mapper to populate the _amr_ claim in OIDC access and ID tokens (for more information on the +AMR claim, see https://www.rfc-editor.org/rfc/rfc8176.html[RFC-8176]). When the _Authentication Method Reference (AMR)_ protocol mapper is configured for a client, it will populate the _amr_ claim with the reference value for any authenticator execution the user successfully completes during the authentication flow. + +.Adding an authenticator reference value +image:images/config-authenticator-reference.png[Configuring an Authenticator Reference Value] + Two types of executions exist, _automatic executions_ and _interactive executions_. _Automatic executions_ are similar to the *Cookie* execution and will automatically perform their action in the flow. _Interactive executions_ halt the flow to get input. Executions executing successfully set their status to _success_. For a flow to complete, it needs at least one execution with a status of _success_. diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index defab163fb..b21ee24694 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2943,3 +2943,7 @@ missingEmailMessage='{{0}}': Please specify email. missingPasswordMessage='{{0}}': Please specify password. referral=Referral referralHelp=Specifies if LDAP referrals should be followed or ignored. Please note that enabling referrals can slow down authentication as it allows the LDAP server to decide which other LDAP servers to use. This could potentially include untrusted servers. +authenticatorRefConfig.value.help=Add a custom reference name for the authenticator. When this authenticator is successfully completed during an authentication flow, the Authentication Method Reference (AMR) protocol mapper will use this value to populate the amr claim of the generated tokens. Note, the AMR protocol must be configured for the given client to populate the AMR claim +authenticatorRefConfig.value.label=Authenticator Reference +authenticatorRefConfig.maxAge.help=The max age in seconds that the authenticator reference value is good for in an SSO session. When the Authentication Method Reference (AMR) protocol mapper is used, the AMR will only be considered valid and populated in the token if the authenticator execution was completed within the specified max age. +authenticatorRefConfig.maxAge.label=Authenticator Reference Max Age \ No newline at end of file diff --git a/js/apps/admin-ui/src/authentication/components/ExecutionConfigModal.tsx b/js/apps/admin-ui/src/authentication/components/ExecutionConfigModal.tsx index e1a44924c7..005ae87d0e 100644 --- a/js/apps/admin-ui/src/authentication/components/ExecutionConfigModal.tsx +++ b/js/apps/admin-ui/src/authentication/components/ExecutionConfigModal.tsx @@ -54,22 +54,57 @@ export const ExecutionConfigModal = ({ formState: { errors }, } = form; + // default config all executions should have + const defaultConfigProperties = execution.authenticationFlow + ? [] + : [ + { + helpText: t("authenticatorRefConfig.value.help"), + label: t("authenticatorRefConfig.value.label"), + name: "default.reference.value", + readOnly: false, + secret: false, + type: "String", + }, + { + helpText: t("authenticatorRefConfig.maxAge.help"), + label: t("authenticatorRefConfig.maxAge.label"), + name: "default.reference.maxAge", + readOnly: false, + secret: false, + type: "String", + }, + ]; + const setupForm = (config?: AuthenticatorConfigRepresentation) => { convertToFormValues(config || {}, setValue); }; useFetch( async () => { - const configDescription = - await adminClient.authenticationManagement.getConfigDescription({ - providerId: execution.providerId!, - }); let config: AuthenticatorConfigRepresentation | undefined; + + const configDescription = execution.configurable + ? await adminClient.authenticationManagement.getConfigDescription({ + providerId: execution.providerId!, + }) + : { + name: execution.displayName, + properties: [], + }; + if (execution.authenticationConfig) { config = await adminClient.authenticationManagement.getConfig({ id: execution.authenticationConfig, }); } + + // merge default and fetched config properties + configDescription.properties = [ + ...defaultConfigProperties!, + ...configDescription.properties!, + ]; + return { configDescription, config }; }, ({ configDescription, config }) => { diff --git a/js/apps/admin-ui/src/authentication/components/FlowRow.tsx b/js/apps/admin-ui/src/authentication/components/FlowRow.tsx index d6e57c846b..10b8b3a0ab 100644 --- a/js/apps/admin-ui/src/authentication/components/FlowRow.tsx +++ b/js/apps/admin-ui/src/authentication/components/FlowRow.tsx @@ -102,9 +102,7 @@ export const FlowRow = ({ /> , - {execution.configurable && ( - - )} + {execution.authenticationFlow && !builtIn && ( <> previouslyAuthenticatedLevel) { // Step-up authentication, we keep the loa from the existing user session. // The cookie alone is not enough and other authentications must follow. diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java index 3fc8044aa4..03c01a28b9 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java @@ -17,17 +17,25 @@ package org.keycloak.authentication.authenticators.util; +import com.fasterxml.jackson.core.type.TypeReference; +import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.common.util.Time; import org.keycloak.events.Errors; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; +import org.keycloak.models.*; import org.keycloak.services.managers.BruteForceProtector; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.Map; /** * @author Vaclav Muzikar */ public final class AuthenticatorUtils { + private static final Logger logger = Logger.getLogger(AuthenticatorUtils.class); + public static String getDisabledByBruteForceEventError(BruteForceProtector protector, KeycloakSession session, RealmModel realm, UserModel user) { if (realm.isBruteForceProtected()) { if (protector.isPermanentlyLockedOut(session, realm, user)) { @@ -44,4 +52,49 @@ public final class AuthenticatorUtils { public static String getDisabledByBruteForceEventError(AuthenticationFlowContext authnFlowContext, UserModel authenticatedUser) { return AuthenticatorUtils.getDisabledByBruteForceEventError(authnFlowContext.getProtector(), authnFlowContext.getSession(), authnFlowContext.getRealm(), authenticatedUser); } + + /** + * Get all completed authenticator executions from the user session notes. + * @param note The serialized note value to parse + * @return A list of execution ids that were successfully completed to create this authentication session + */ + public static Map parseCompletedExecutions(String note){ + // default to empty map + if (note == null){ + note = "{}"; + } + + try { + return JsonSerialization.readValue(note, new TypeReference>() {}); + } catch (IOException e) { + logger.warnf("Invalid format of the completed authenticators map. Saved value was: %s", note); + throw new IllegalStateException(e); + } + } + + /** + * Update the completed authenticators note on the new auth session + * @param authSession The current authentication session + * @param userSession The previous user session + * @param executionId The completed execution id + */ + public static void updateCompletedExecutions(AuthenticationSessionModel authSession, UserSessionModel userSession, String executionId){ + Map completedExecutions = parseCompletedExecutions(authSession.getUserSessionNotes().get(Constants.AUTHENTICATORS_COMPLETED)); + + // attempt to fetch previously completed authenticators + if (userSession != null){ + Map prevCompleted = parseCompletedExecutions(userSession.getNote(Constants.AUTHENTICATORS_COMPLETED)); + logger.debugf("merging completed executions from previous authentication session %s", prevCompleted); + completedExecutions.putAll(prevCompleted); + } + + // set new execution and serialize note + completedExecutions.put(executionId, Time.currentTime()); + try { + String updated = JsonSerialization.writeValueAsString(completedExecutions); + authSession.setUserSessionNote(Constants.AUTHENTICATORS_COMPLETED, updated); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/AmrProtocolMapper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/AmrProtocolMapper.java new file mode 100644 index 0000000000..12d09f86c0 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/AmrProtocolMapper.java @@ -0,0 +1,112 @@ +/* + * Copyright 2022 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.oidc.mappers; + +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.authenticators.util.AuthenticatorUtils; +import org.keycloak.models.*; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.AmrUtils; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.IDToken; + +import java.util.HashMap; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; + +/** + * @author Ben Cresitello-Dittmar + * This protocol mapper sets the 'amr' claim on the OIDC tokens to the reference values configured on the + * completed authenticators found in the user session notes. + */ +public class AmrProtocolMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper, OIDCIDTokenMapper, EnvironmentDependentProviderFactory { + + private static final Logger logger = Logger.getLogger(AmrProtocolMapper.class); + + public static final String PROVIDER_ID = "oidc-amr-mapper"; + + public List getConfigProperties() { + List configProperties = new ArrayList<>(); + OIDCAttributeMapperHelper.addIncludeInTokensConfig(configProperties, AmrProtocolMapper.class); + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Authentication Method Reference (AMR)"; + } + + @Override + public String getDisplayCategory() { + return TOKEN_MAPPER_CATEGORY; + } + + @Override + public String getHelpText() { + return "Add authentication method reference (AMR) to the token."; + } + + @Override + protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, + ClientSessionContext clientSessionCtx) { + AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession(); + List amr = getAmr(clientSession, userSession.getRealm()); + token.setOtherClaims(OAuth2Constants.AUTHENTICATOR_METHOD_REFERENCE, amr); + } + + public static ProtocolMapperModel create(String name, boolean accessToken, boolean idToken) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map config = new HashMap<>(); + if (accessToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + mapper.setConfig(config); + return mapper; + } + + /** + * Extract the AMR values from the existing session. + * + * @param clientSession The existing authenticated session + * @param realmModel The realm the mapper is executed in. Used to get the execution configuration. + * @return The authenticator reference values associated with the completed executions + */ + protected List getAmr(AuthenticatedClientSessionModel clientSession, RealmModel realmModel) { + Map executions = AuthenticatorUtils.parseCompletedExecutions(clientSession.getUserSession().getNote(Constants.AUTHENTICATORS_COMPLETED)); + logger.debugf("found the following completed authentication executions: %s", executions.toString()); + List refs = AmrUtils.getAuthenticationExecutionReferences(executions, realmModel); + logger.debugf("amr %s set in token", refs); + return refs; + } + + @Override + public boolean isSupported() { + return true; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/AmrUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/AmrUtils.java new file mode 100644 index 0000000000..b945fa6ab4 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/AmrUtils.java @@ -0,0 +1,80 @@ +/* + * Copyright 2021 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.oidc.utils; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.RealmModel; +import org.keycloak.models.Constants; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @author Ben Cresitello-Dittmar + * Utility for parsing authenticator method reference (AMR) values. + */ +public class AmrUtils { + private static final Logger logger = Logger.getLogger(AmrUtils.class); + + /** + * Get the configured authenticator reference values for the specified executions. If no + * value is configured for the execution, null is returned instead of throwing an error. + * + * @param executions List of authenticator execution ids + * @param realmModel The realm the executions are configured in + * @return The list of amr values. + */ + public static List getAuthenticationExecutionReferences(Map executions, RealmModel realmModel) { + return executions.entrySet().stream() + .map( + entry -> { + try { + // extract the authenticator config and get the authenticator reference value + Map config = realmModel.getAuthenticatorConfigById(realmModel.getAuthenticationExecutionById(entry.getKey()).getAuthenticatorConfig()).getConfig(); + if (isAmrValid(config, entry.getValue())){ + return config.get(Constants.AUTHENTICATION_EXECUTION_REFERENCE_VALUE); + } + } catch (NullPointerException e){ + return null; + } + + return null; + } + ).filter( + ref -> ref != null && !ref.isEmpty() + ).collect(Collectors.toList()); + } + + /** + * Check if the AMR is still valid by determining if the execution time + the configured max age is less than the current time + * @param config The authenticator execution config + * @param authTime The time that the authentication occurred + * @return True if the amr value is still valid for this session + */ + public static boolean isAmrValid(Map config, Integer authTime){ + try { + int maxAge = Integer.parseInt(config.getOrDefault(Constants.AUTHENTICATION_EXECUTION_REFERENCE_MAX_AGE, "0")); + return authTime + maxAge >= Time.currentTime(); + } catch (NumberFormatException e){ + logger.warnf("invalid authentication execution max age '%s'", config.get(Constants.AUTHENTICATION_EXECUTION_REFERENCE_MAX_AGE)); + } + return false; + } +} 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 8daf82bbd1..544200a95b 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 @@ -28,6 +28,7 @@ org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper org.keycloak.protocol.oidc.mappers.AcrProtocolMapper +org.keycloak.protocol.oidc.mappers.AmrProtocolMapper org.keycloak.protocol.saml.mappers.RoleListMapper org.keycloak.protocol.saml.mappers.RoleNameMapper org.keycloak.protocol.saml.mappers.HardcodedRole diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AuthenticationMethodReferenceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AuthenticationMethodReferenceTest.java new file mode 100644 index 0000000000..71fc9d70b6 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AuthenticationMethodReferenceTest.java @@ -0,0 +1,542 @@ +/* + * Copyright 2021 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.oidc; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory; +import org.keycloak.events.Details; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.Constants; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.representations.ClaimsRepresentation; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.idm.*; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.forms.LevelOfAssuranceFlowTest; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.LoginTotpPage; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; +import org.keycloak.testsuite.util.FlowUtil; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +/** + * @author Ben Cresitello-Dittmar + * Test for the OIDC authentication method reference (AMR) feature and protocol mapper. + */ +public class AuthenticationMethodReferenceTest extends AbstractOIDCScopeTest{ + + // config + private static String AMR_VALUE_KEY = Constants.AUTHENTICATION_EXECUTION_REFERENCE_VALUE; + private static String AMR_MAX_AGE_KEY = Constants.AUTHENTICATION_EXECUTION_REFERENCE_MAX_AGE; + private static Integer DEFAULT_MAX_AGE = 120; + private static String CLIENT_ID = "test-app"; + private static String CLIENT_SECRET = "password"; + private static String PASSWORD = "password"; + private static String TOTP_SECRET = "totpsecret"; + + // pages + @Page + protected LoginTotpPage loginTotpPage; + + @Page + protected LoginPage loginPage; + + private TimeBasedOTP totp = new TimeBasedOTP(); + private static String passwordUserId; + private static String totpUserId; + + /** + * Create the AMR protocol mapper and add it to the test OIDC client. + * @param testRealm The realm read from /testrealm.json. + */ + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + // setup user + UserRepresentation user = createTestUser("test-user", PASSWORD, null); + UserRepresentation totpUser = createTestUser("totp-user", PASSWORD, TOTP_SECRET); + testRealm.getUsers().add(user); + testRealm.getUsers().add(totpUser); + passwordUserId = user.getId(); + totpUserId = totpUser.getId(); + + // setup amr scope + List newScopes = createScopes("oidc-amr-mapper", "oidc-acr-mapper"); + + List scopes = testRealm.getClientScopes(); + if (scopes == null){ + testRealm.setClientScopes(new ArrayList<>()); + } + for (ClientScopeRepresentation newScope : newScopes){ + testRealm.getClientScopes().add(newScope); + } + + // update client and default scopes + List scopeNames = newScopes.stream().map(ClientScopeRepresentation::getName).collect(Collectors.toList()); + testRealm.setDefaultDefaultClientScopes(scopeNames); + testRealm.getClients().stream().filter(c -> c.getClientId().equals(CLIENT_ID)).findFirst().orElseThrow().setDefaultClientScopes(scopeNames); + } + + /** + * Helper function to create a test user, optionally with OTP configured + * @param username The username of the user to create + * @param password The password to set on the user + * @param totpSecret If set, will configure a totp authenticator with this secret + * @return + */ + private UserRepresentation createTestUser(String username, String password, String totpSecret){ + UserBuilder builder = UserBuilder.create() + .id(KeycloakModelUtils.generateId()) + .username(username) + .enabled(true) + .email(username + "@email.com") + .firstName(username) + .lastName(username) + .password(password); + + if (totpSecret != null){ + builder.totpSecret(totpSecret) + .otpEnabled(); + } + + return builder.build(); + } + + /** + * Helper function to create the AMR scope and protocol mapper. + * @return The created scope object + */ + private List createScopes(String ...mappers){ + List scopes = new ArrayList<>(); + for (String mapper : mappers){ + ProtocolMapperRepresentation protocolMapper = createMapper(mapper); + ClientScopeRepresentation scope = new ClientScopeRepresentation(); + scope.setId(KeycloakModelUtils.generateId()); + scope.setName(mapper); + scope.setProtocol("openid-connect"); + scope.setAttributes(new HashMap<>() {{ + put(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "false"); + put(ClientScopeModel.DISPLAY_ON_CONSENT_SCREEN, "false"); + }}); + scope.setProtocolMappers(Collections.singletonList(protocolMapper)); + scopes.add(scope); + } + + return scopes; + } + + /** + * Helper function to create the amr protocol mapper. + * @return The created protocol mapper + */ + private ProtocolMapperRepresentation createMapper(String mapper){ + ProtocolMapperRepresentation protocolMapper = new ProtocolMapperRepresentation(); + protocolMapper.setId(KeycloakModelUtils.generateId()); + protocolMapper.setName(mapper); + protocolMapper.setProtocol("openid-connect"); + protocolMapper.setProtocolMapper(mapper); + protocolMapper.setConfig(new HashMap<>() {{ + put("id.token.claim", "true"); + put("access.token.claim", "true"); + }}); + return protocolMapper; + } + + /** + * Set the OIDC client before each test. + */ + @Before + public void configureClient() { + oauth.clientId(CLIENT_ID); + } + + /** + * Clear the configured authenticator reference values in the authentication flow after each test + */ + @After + public void cleanup() { + clearAmr("browser"); + + // remove claims config for acr + ClaimsRepresentation claims = new ClaimsRepresentation(); + claims.setIdTokenClaims(Collections.emptyMap()); + oauth.claims(claims); + + // reset default browser flow + setBrowserFlow("browser"); + + // allow otp code reuse + new RealmAttributeUpdater(testRealm()) + .setOtpPolicyCodeReusable(true) + .update(); + } + + /** + * Helper function to clear the amr configuration from the given flow + * @param flowAlias + */ + private void clearAmr(String flowAlias){ + getAuthenticatorConfigs(flowAlias).forEach(c -> { + Map configVals = c.getConfig(); + configVals.put(AMR_VALUE_KEY, null); + configVals.put(AMR_MAX_AGE_KEY, null); + c.setConfig(configVals); + + testRealm().flows().updateAuthenticatorConfig(c.getId(), c); + }); + } + + /** + * Get all authenticator configs for a given authentication flow + * @param flowAlias The alias of the flow + * @return A list of authenticator configs from the specified flow + */ + private List getAuthenticatorConfigs(String flowAlias){ + return testRealm().flows().getExecutions(flowAlias).stream().filter(e -> e.getAuthenticationConfig() != null).map(e -> testRealm().flows().getAuthenticatorConfig(e.getAuthenticationConfig())).collect(Collectors.toList()); + } + + /** + * Test the AMR protocol mapper if no authenticator references are configured in the authentication flow + * Expected: AMR = [] + */ + @Test + public void testAmrNone() { + List expectedAmrs = new ArrayList<>(); + authenticatePassword("test-user", PASSWORD); + Tokens tokens = assertLogin(passwordUserId); + + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + + logout(passwordUserId, tokens); + } + + /** + * Test the AMR protocol mapper if only the password form authenticator have an authenticator reference configured + * Expected: AMR = ["password"] + */ + @Test + public void testAmrPassword() { + setAmr("browser", "auth-username-password-form", "password"); + + List expectedAmrs = new ArrayList<>(){{ + add("password"); + }}; + authenticatePassword("test-user", PASSWORD); + Tokens tokens = assertLogin(passwordUserId); + + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + + logout(passwordUserId, tokens); + } + + /** + * Test the AMR protocol mapper if the password form and totp form have an authenticator reference configured + * Expected: AMR = ["password", "totp"] + */ + @Test + public void testAmrPasswordTotp() { + setAmr("browser", "auth-username-password-form", "password"); + setAmr("browser", "auth-otp-form", "totp"); + + List expectedAmrs = new ArrayList<>(){{ + add("password"); + add("totp"); + }}; + authenticatePassword("totp-user", PASSWORD); + authenticateTOTP(TOTP_SECRET); + Tokens tokens = assertLogin(totpUserId); + + + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + + logout(totpUserId, tokens); + } + + /** + * Test the AMR protocol mapper if the max age of the stored amr value has been passed + * Expected: AMR = ["password"] + */ + @Test + public void testAmrPastMaxAge() { + setAmr("browser", "auth-username-password-form", "password", 0); + + List expectedAmrs = new ArrayList<>(); + authenticatePassword("test-user", PASSWORD); + + // server time forward by 60 seconds to ensure max age is exceeded + setTimeOffset(60); + + Tokens tokens = assertLogin(passwordUserId); + + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + + logout(passwordUserId, tokens); + } + + /** + * Test the AMR protocol mapper if the max age of the stored amr value has been not been passed + * Expected: AMR = ["password"] + */ + @Test + public void testAmrWithinMaxAge() { + Tokens tokens; + + setAmr("browser", "auth-username-password-form", "password", 60); + List expectedAmrs = new ArrayList<>(){{ + add("password"); + }}; + + // perform initial login + authenticatePassword("test-user", PASSWORD); + tokens = assertLogin(passwordUserId); + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + getLogger().info(tokens.accessToken.getId()); + + // re-open login page to login with cookie - ensure amr values persist + oauth.openLoginForm(); + tokens = assertLogin(passwordUserId); + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + getLogger().info(tokens.accessToken.getId()); + + logout(passwordUserId, tokens); + } + + /** + * Test the AMR protocol mapper during step up authentication + */ + @Test + public void testAmrStepUp() { + Tokens tokens; + List expectedAmrs = new ArrayList<>(){{ + add("password"); + }}; + + // configure acr loa + configureStepUpFlow("browser step-up"); + setBrowserFlow("browser step-up"); + configureRealmAcrMap(new HashMap<>(){{ + put("silver", 1); + put("gold", 2); + }}); + + // configure amr + setAmr("browser step-up", "auth-username-password-form", "password"); + setAmr("browser step-up", "auth-otp-form", "totp"); + + // login at level 1 + LevelOfAssuranceFlowTest.openLoginFormWithAcrClaim(oauth,true, "silver"); + authenticatePassword("totp-user", PASSWORD); + tokens = assertLogin(totpUserId); + assertAcr(tokens.idToken, "silver"); + assertAcr(tokens.accessToken, "silver"); + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + + // step-up to level 2 + expectedAmrs.add("totp"); + LevelOfAssuranceFlowTest.openLoginFormWithAcrClaim(oauth, true, "gold"); + authenticateTOTP(TOTP_SECRET); + tokens = assertLogin(totpUserId); + assertAcr(tokens.idToken, "gold"); + assertAcr(tokens.accessToken, "gold"); + assertAmr(tokens.idToken, expectedAmrs); + assertAmr(tokens.accessToken, expectedAmrs); + + logout(totpUserId, tokens); + } + + private void setAmr(String flowAlias, String providerId, String amrValue){ + setAmr(flowAlias, providerId, amrValue, DEFAULT_MAX_AGE); + } + + /** + * Helper function to set the authenticator reference config on the specified provider in the given flow + * @param flowAlias The authentication flow to search + * @param providerId The provider ID of the authentication execution within the specified flow + * @param amrValue The authenticator reference value to set on the authenticator config + * @param maxAge The max age the authenticator reference value is valid + */ + private void setAmr(String flowAlias, String providerId, String amrValue, Integer maxAge){ + AuthenticationExecutionInfoRepresentation execution = testRealm().flows().getExecutions(flowAlias).stream().filter(e -> e.getProviderId() != null && e.getProviderId().equals(providerId)).findFirst().orElseThrow(); + + if (execution.getAuthenticationConfig() == null){ + // create config if it doesn't exist + AuthenticatorConfigRepresentation config = new AuthenticatorConfigRepresentation(); + config.setAlias("test"); + config.setConfig(new HashMap<>(){{ + put(AMR_VALUE_KEY, amrValue); + put(AMR_MAX_AGE_KEY, maxAge.toString()); + }}); + + testRealm().flows().newExecutionConfig(execution.getId(), config); + } else { + // update existing config + AuthenticatorConfigRepresentation config = testRealm().flows().getAuthenticatorConfig(execution.getAuthenticationConfig()); + Map newConfig = config.getConfig(); + newConfig.put(AMR_VALUE_KEY, amrValue); + newConfig.put(AMR_MAX_AGE_KEY, maxAge.toString()); + config.setConfig(newConfig); + testRealm().flows().updateAuthenticatorConfig(config.getId(), config); + } + } + + /** + * Helper function to set the browser flow for the realm + * @param flowAlias The alias of the flow to set as the browser flow + */ + private void setBrowserFlow(String flowAlias){ + testingClient.server(TEST_REALM_NAME) + .run(session -> FlowUtil.inCurrentRealm(session).selectFlow(flowAlias).defineAsBrowserFlow()); + } + + /** + * Helper function to configure the realm acr loa map + * @param acrLoaMap The map to set + */ + private void configureRealmAcrMap(Map acrLoaMap){ + RealmRepresentation realmRep = testRealm().toRepresentation(); + try { + realmRep.getAttributes().put(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap)); + } catch (IOException e){ + throw new RuntimeException("failed to parse acr loa map"); + } + testRealm().update(realmRep); + } + + /** + * Helper function to configure a step-up flow. + * Flow: acr=1 -> password, acr=2 -> totp + */ + private void configureStepUpFlow(String newFlowAlias) { + testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server(TEST_REALM_NAME) + .run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear() + // level 1 authentication + .addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1"); + config.getConfig().put(ConditionalLoaAuthenticator.MAX_AGE, String.valueOf(60)); + }); + + // username input for level 1 + subFlow.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernamePasswordFormFactory.PROVIDER_ID); + }) + + // level 2 authentication + .addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2"); + config.getConfig().put(ConditionalLoaAuthenticator.MAX_AGE, String.valueOf(60)); + }); + + // password required for level 2 + subFlow.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, OTPFormAuthenticatorFactory.PROVIDER_ID); + }) + )); + } + + /** + * Helper function to log out the specified user + * @param userId The keycloak identifier of the user + * @param tokens The OIDC tokens received during login + */ + private void logout(String userId, Tokens tokens){ + // Logout + oauth.doLogout(tokens.refreshToken, CLIENT_SECRET); + events.expectLogout(tokens.idToken.getSessionState()) + .client(CLIENT_ID) + .user(userId) + .removeDetail(Details.REDIRECT_URI).assertEvent(); + } + + /** + * Helper function to authenticate with a username and password + * @param username The username to log in with + * @param password The password to log in with + */ + private void authenticatePassword(String username, String password){ + loginPage.open(); + loginPage.login(username, password); + } + + /** + * Helper function to authenticate with a TOTP token + * @param totpSecret The secret to use to generate the TOTP token + */ + private void authenticateTOTP(String totpSecret){ + org.junit.Assert.assertTrue(loginTotpPage.isCurrent()); + setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp); + + loginTotpPage.login(totp.generateTOTP(totpSecret)); + } + + /** + * Helper function to assert login completed successfully for the specified user + * @param userId The keycloak ID of the user to check + * @return The tokens from a successful login + */ + private Tokens assertLogin(String userId){ + EventRepresentation loginEvent = events.expectLogin() + .user(userId) + .assertEvent(); + + return sendTokenRequest(loginEvent, userId, "openid", CLIENT_ID); + } + + /** + * Helper function to assert the token contains the specified amr values + * @param token The token to check (either access or ID) + * @param expectedValues The expected amr values in the token + */ + private void assertAmr(IDToken token, List expectedValues) { + getLogger().infof("Got claims %s", token.getOtherClaims().toString()); + List amr = (List) token.getOtherClaims().get("amr"); + Assert.assertNotNull(amr); + + // sort otherwise order may be different + Collections.sort(amr); + Collections.sort(expectedValues); + + Assert.assertArrayEquals(expectedValues.toArray(), amr.toArray()); + } + + private void assertAcr(IDToken token, String acr){ + Assert.assertEquals(acr, token.getAcr()); + } + +}