Implement Authentication Method Reference (AMR) claim from OIDC specification

This implements a method for configuring authenticator reference values for Keycloak authenticator executions and a protocol mapper for populating the AMR claim in the resulting OIDC tokens.

This implementation adds a default configuration item to each authenticator execution, allowing administrators to configure an authenticator reference value. Upon successful completion of an authenticator during an authentication flow, Keycloak tracks the execution ID in a user session note.

The protocol mapper pulls the list of completed authenticators from the user session notes and loads the associated configurations for each authenticator execution. It then captures the list of authenticator references from these configs and sets it in the AMR claim of the resulting tokens.

Closes #19190

Signed-off-by: Ben Cresitello-Dittmar <bcresitellodittmar@mitre.org>
This commit is contained in:
Ben Cresitello-Dittmar 2023-09-14 16:04:18 -04:00 committed by Pedro Igor
parent 07f9ead128
commit 057d8a00ac
14 changed files with 857 additions and 16 deletions

View file

@ -148,6 +148,8 @@ public interface OAuth2Constants {
// https://www.rfc-editor.org/rfc/rfc9207.html // https://www.rfc-editor.org/rfc/rfc9207.html
String ISSUER = "iss"; String ISSUER = "iss";
String AUTHENTICATOR_METHOD_REFERENCE = "amr";
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View file

@ -111,6 +111,12 @@ Executions have a wide variety of actions, from sending a reset email to validat
.Adding an authentication execution .Adding an authentication execution
image:images/Create-authentication-execution.png[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 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_. 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_.

View file

@ -2943,3 +2943,7 @@ missingEmailMessage='{{0}}': Please specify email.
missingPasswordMessage='{{0}}': Please specify password. missingPasswordMessage='{{0}}': Please specify password.
referral=Referral 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. 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

View file

@ -54,22 +54,57 @@ export const ExecutionConfigModal = ({
formState: { errors }, formState: { errors },
} = form; } = 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) => { const setupForm = (config?: AuthenticatorConfigRepresentation) => {
convertToFormValues(config || {}, setValue); convertToFormValues(config || {}, setValue);
}; };
useFetch( useFetch(
async () => { async () => {
const configDescription =
await adminClient.authenticationManagement.getConfigDescription({
providerId: execution.providerId!,
});
let config: AuthenticatorConfigRepresentation | undefined; let config: AuthenticatorConfigRepresentation | undefined;
const configDescription = execution.configurable
? await adminClient.authenticationManagement.getConfigDescription({
providerId: execution.providerId!,
})
: {
name: execution.displayName,
properties: [],
};
if (execution.authenticationConfig) { if (execution.authenticationConfig) {
config = await adminClient.authenticationManagement.getConfig({ config = await adminClient.authenticationManagement.getConfig({
id: execution.authenticationConfig, id: execution.authenticationConfig,
}); });
} }
// merge default and fetched config properties
configDescription.properties = [
...defaultConfigProperties!,
...configDescription.properties!,
];
return { configDescription, config }; return { configDescription, config };
}, },
({ configDescription, config }) => { ({ configDescription, config }) => {

View file

@ -102,9 +102,7 @@ export const FlowRow = ({
/> />
</DataListCell>, </DataListCell>,
<DataListCell key={`${execution.id}-config`}> <DataListCell key={`${execution.id}-config`}>
{execution.configurable && (
<ExecutionConfigModal execution={execution} /> <ExecutionConfigModal execution={execution} />
)}
{execution.authenticationFlow && !builtIn && ( {execution.authenticationFlow && !builtIn && (
<> <>
<AddFlowDropdown <AddFlowDropdown

View file

@ -140,6 +140,13 @@ public final class Constants {
// Authentication session note, which contains loa of current authentication in progress // Authentication session note, which contains loa of current authentication in progress
public static final String LEVEL_OF_AUTHENTICATION = "level-of-authentication"; public static final String LEVEL_OF_AUTHENTICATION = "level-of-authentication";
// Key in authentication execution config (AuthenticationExecutionModel), storing the configured authentication reference value
public static final String AUTHENTICATION_EXECUTION_REFERENCE_VALUE = "default.reference.value";
public static final String AUTHENTICATION_EXECUTION_REFERENCE_MAX_AGE = "default.reference.maxAge";
// Authentication session note containing a serialized map of successfully completed authentication executions and their associated times
public static final String AUTHENTICATORS_COMPLETED = "authenticators-completed";
// Authentication session (and user session) note, which contains map with authenticated levels and the times of their authentications, // Authentication session (and user session) note, which contains map with authenticated levels and the times of their authentications,
// so it is possible to check when particular level expires and needs to be re-authenticated // so it is possible to check when particular level expires and needs to be re-authenticated
public static final String LOA_MAP = "loa-map"; public static final String LOA_MAP = "loa-map";

View file

@ -19,6 +19,7 @@ package org.keycloak.authentication;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator; import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator;
import org.keycloak.authentication.authenticators.util.AuthenticatorUtils;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
@ -31,12 +32,8 @@ import org.keycloak.utils.StringUtil;
import jakarta.ws.rs.HttpMethod; import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.Iterator; import java.util.*;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -485,6 +482,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
case SUCCESS: case SUCCESS:
logger.debugv("authenticator SUCCESS: {0}", execution.getAuthenticator()); logger.debugv("authenticator SUCCESS: {0}", execution.getAuthenticator());
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.SUCCESS); setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.SUCCESS);
AuthenticatorUtils.updateCompletedExecutions(processor.getAuthenticationSession(), processor.getUserSession(), execution.getId());
return null; return null;
case FAILED: case FAILED:
logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator()); logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator());

View file

@ -20,6 +20,7 @@ package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.authenticators.util.AcrStore; import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.authentication.authenticators.util.AuthenticatorUtils;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
@ -62,6 +63,8 @@ public class CookieAuthenticator implements Authenticator {
context.attempted(); context.attempted();
} else { } else {
int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication(); int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication();
AuthenticatorUtils.updateCompletedExecutions(context.getAuthenticationSession(), authResult.getSession(), context.getExecution().getId());
if (acrStore.getRequestedLevelOfAuthentication() > previouslyAuthenticatedLevel) { if (acrStore.getRequestedLevelOfAuthentication() > previouslyAuthenticatedLevel) {
// Step-up authentication, we keep the loa from the existing user session. // Step-up authentication, we keep the loa from the existing user session.
// The cookie alone is not enough and other authentications must follow. // The cookie alone is not enough and other authentications must follow.

View file

@ -17,17 +17,25 @@
package org.keycloak.authentication.authenticators.util; 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.authentication.AuthenticationFlowContext;
import org.keycloak.common.util.Time;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.*;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.managers.BruteForceProtector; 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 <vmuzikar@redhat.com> * @author Vaclav Muzikar <vmuzikar@redhat.com>
*/ */
public final class AuthenticatorUtils { 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) { public static String getDisabledByBruteForceEventError(BruteForceProtector protector, KeycloakSession session, RealmModel realm, UserModel user) {
if (realm.isBruteForceProtected()) { if (realm.isBruteForceProtected()) {
if (protector.isPermanentlyLockedOut(session, realm, user)) { if (protector.isPermanentlyLockedOut(session, realm, user)) {
@ -44,4 +52,49 @@ public final class AuthenticatorUtils {
public static String getDisabledByBruteForceEventError(AuthenticationFlowContext authnFlowContext, UserModel authenticatedUser) { public static String getDisabledByBruteForceEventError(AuthenticationFlowContext authnFlowContext, UserModel authenticatedUser) {
return AuthenticatorUtils.getDisabledByBruteForceEventError(authnFlowContext.getProtector(), authnFlowContext.getSession(), authnFlowContext.getRealm(), 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<String, Integer> parseCompletedExecutions(String note){
// default to empty map
if (note == null){
note = "{}";
}
try {
return JsonSerialization.readValue(note, new TypeReference<Map<String, Integer>>() {});
} 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<String, Integer> completedExecutions = parseCompletedExecutions(authSession.getUserSessionNotes().get(Constants.AUTHENTICATORS_COMPLETED));
// attempt to fetch previously completed authenticators
if (userSession != null){
Map<String, Integer> 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);
}
}
} }

View file

@ -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<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> 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<String> 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<String, String> 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<String> getAmr(AuthenticatedClientSessionModel clientSession, RealmModel realmModel) {
Map<String, Integer> executions = AuthenticatorUtils.parseCompletedExecutions(clientSession.getUserSession().getNote(Constants.AUTHENTICATORS_COMPLETED));
logger.debugf("found the following completed authentication executions: %s", executions.toString());
List<String> refs = AmrUtils.getAuthenticationExecutionReferences(executions, realmModel);
logger.debugf("amr %s set in token", refs);
return refs;
}
@Override
public boolean isSupported() {
return true;
}
}

View file

@ -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<String> getAuthenticationExecutionReferences(Map<String, Integer> executions, RealmModel realmModel) {
return executions.entrySet().stream()
.map(
entry -> {
try {
// extract the authenticator config and get the authenticator reference value
Map<String, String> 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<String, String> 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;
}
}

View file

@ -28,6 +28,7 @@ org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper
org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper
org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper
org.keycloak.protocol.oidc.mappers.AcrProtocolMapper org.keycloak.protocol.oidc.mappers.AcrProtocolMapper
org.keycloak.protocol.oidc.mappers.AmrProtocolMapper
org.keycloak.protocol.saml.mappers.RoleListMapper org.keycloak.protocol.saml.mappers.RoleListMapper
org.keycloak.protocol.saml.mappers.RoleNameMapper org.keycloak.protocol.saml.mappers.RoleNameMapper
org.keycloak.protocol.saml.mappers.HardcodedRole org.keycloak.protocol.saml.mappers.HardcodedRole

View file

@ -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<ClientScopeRepresentation> newScopes = createScopes("oidc-amr-mapper", "oidc-acr-mapper");
List<ClientScopeRepresentation> scopes = testRealm.getClientScopes();
if (scopes == null){
testRealm.setClientScopes(new ArrayList<>());
}
for (ClientScopeRepresentation newScope : newScopes){
testRealm.getClientScopes().add(newScope);
}
// update client and default scopes
List<String> 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<ClientScopeRepresentation> createScopes(String ...mappers){
List<ClientScopeRepresentation> 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<String, String> 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<AuthenticatorConfigRepresentation> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String, String> 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<String, Integer> 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<String> expectedValues) {
getLogger().infof("Got claims %s", token.getOtherClaims().toString());
List<String> amr = (List<String>) 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());
}
}