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:
parent
07f9ead128
commit
057d8a00ac
14 changed files with 857 additions and 16 deletions
|
@ -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 |
|
@ -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_.
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue