8000 Enforce the essential acr level when the flow finishes without a step-up by michalvavrik · Pull Request #53587 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/main/java/org/keycloak/OAuthErrorException.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public class OAuthErrorException extends Exception {
// OpenID Connect 1
public static final String INTERACTION_REQUIRED = "interaction_required";
public static final String LOGIN_REQUIRED = "login_required";
public static final String UNMET_AUTHENTICATION_REQUIREMENTS = "unmet_authentication_requirements";
public static final String REQUEST_NOT_SUPPORTED = "request_not_supported";
public static final String REQUEST_URI_NOT_SUPPORTED = "request_uri_not_supported";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ Creating an advanced flow such as this can have side effects. For example, if yo
<<_client_policies, Client Policies>> can be used to dynamically select an Authentication Flow based on specific conditions, such as requesting a particular scope or an ACR (Authentication Context Class Reference) using the `AuthenticationFlowSelectorExecutor` in combination with the condition you prefer.

The `AuthenticationFlowSelectorExecutor` allows you to select an appropriate authentication flow and set the level of authentication to be applied once the selected flow is completed.
A registration or a password reset started from the login page does not complete the selected flow and does not reach that level.

A possible configuration involves using the `ACRCondition` in combination with the `AuthenticationFlowSelectorExecutor`. This setup enables you to select an authentication flow based on the requested ACR and have the ACR value included in the token using <<_mapping-acr-to-loa-realm,ACR to LoA Mapping>>.

Expand Down Expand Up @@ -422,9 +423,18 @@ and hence will be added to all newly created clients in the realm.

In case you do not want `acr` claim inside tokens or you need some custom logic for adding it, you can remove the client scope from your client.

Note when the login request initiates a request with the `claims` parameter requesting `acr` as `essential` claim, then {project_name} will always return
one of the specified levels. If it is not able to return one of the specified levels (For example if the requested level is unknown or bigger than configured conditions
in the authentication flow), then {project_name} will throw an error.
When the login request uses the `claims` parameter to request `acr` as an essential claim, {project_name} returns one of the requested levels or rejects the login.
The login is rejected, for example, when the requested level is unknown or higher than the level of any condition in the authentication flow.
The requested level is enforced in every authentication flow that completes the login:

* A flow without a Conditional - Level Of Authentication condition does not record a level. An active login reaches level 1 and a login based only on the SSO session reaches level 0, the same levels that the `acr` claim reports.
A request for level 0 is satisfied by an existing session without asking the user to authenticate again.
An active login in such a flow reaches at least level 1 even if the session previously reached other levels in a different flow.
* A flow selected by a client policy reaches the level configured in the policy instead, see <<_client-policy-auth-flow, Using Client Policies to Select an Authentication Flow>>.
* A registration or a password reset that cannot reach the requested level fails before the account is created or the password is changed.

A rejected interactive login ends on an error page.
A rejected login with `prompt=none` returns the error `unmet_authentication_requirements` if the login completed without user interaction, for example through the SSO session or a client certificate, or `login_required` if reaching the requested level requires user interaction.

[[_step-up-authentication-saml]]
==== Step-up authentication for SAML
Expand Down Expand Up @@ -494,15 +504,17 @@ When the `step-up-authentication-saml` feature is enabled, the <<_mapping-acr-to

In summary, when the step-up authentication is configured for SAML, {project_name} will process the specified context level in the SAML request and the minimum ACR allowed for the client (if they are present) to obtain the LoA (integer level) that should be reached in the authentication. If there is no available level that satisfies the request, the error is returned per specification. If the URI/LoA mapping returns a level that satisfies the request, the authentication flow is started, enforcing that Level of Authentication to be reached.

In order to maintain backwards compatibility, {project_name} does not return an error and continues adding the previous ACR `urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified` to the response in the following situations:
For backwards compatibility, {project_name} adds the authentication context `urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified` to the response in the following situations:

* The feature `step-up-authentication-saml` is not enabled in {project_name}.
* The SAML client does not define any mapping between context URI and LoA. Use the client mapping instead of the general realm mapping when you just need to apply the step-up for some specific SAML clients.
* The `AuthnContextClassRef mapper` is not executed. This mapper is provided by a new default client scope `AuthnContextClassRef` which is now added to SAML clients by default. It is in charge of adding the resulting `<AuthnContextClassRef>` to the response.
+
NOTE: The mapper and the client scope `AuthnContextClassRef` are automatically created and assigned to SAML clients when the `step-up-authentication-saml` feature is enabled (for new and migrated realms). If the feature was disabled at creation or migration time and want to be used afterwards, the client scope with the mapper should be created and assigned to the client manually.
+
* The LoA calculated at request time is not achieved by the authentication flow. For example, if the authentication flow used for authentication is not a step-up flow, or there is a misconfiguration between the URI/LoA mapping and the final level reached in the step-up authentication flow.
* The authentication flow is not a step-up flow and therefore records no level. {project_name} still enforces the requested level: an active login reaches level 1 and a login based only on the SSO session reaches level 0.

If the authentication flow does not reach the requested level, the login is rejected. A rejected interactive login ends on an error page. A rejected passive login returns a response with the `NoAuthnContext` status.

[[_registration-rc-client-flows]]
==== Registration or Reset credentials requested by client
Expand Down
14 changes: 14 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ The provider's last-sync time is now recorded when a sync starts rather than whe

If you previously lowered `fullSyncPeriod` or `changedSyncPeriod` to compensate for the doubled interval, review those values.

=== Stricter enforcement of the requested level of authentication
// https://github.com/keycloak/keycloak/issues/53157

Previously, {project_name} could complete a login at a lower level of authentication than the client requested, when the authentication flow skipped the steps for the requested level, usually because the user already had a session.
{project_name} now rejects such logins.
For OpenID Connect clients, the requested level is the level of an essential `acr` claim.
For SAML clients, it is the level of the requested authentication context or of the minimum ACR value of the client.
The level is now enforced in every authentication flow, including flows without a `Conditional - Level Of Authentication` condition and the registration and reset credentials flows.
An active login in a flow without the condition now always reaches at least level 1, and the `acr` claim reports it accordingly.

A rejected interactive login ends on an error page.
A rejected passive login returns the error `unmet_authentication_requirements` or `login_required` to an OpenID Connect client and the `NoAuthnContext` status to a SAML client.
For the level that each flow reaches, see link:{adminguide_link}#_step-up-flow[Creating a browser login flow with step-up mechanism] in the {adminguide_name}.

// ------------------------ Deprecated features ------------------------ //
== Deprecated features

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ public final class Constants {
public static final String MINIMUM_ACR_VALUE = "minimum.acr.value";
public static final int MINIMUM_LOA = 0;
public static final int NO_LOA = -1;
public static final int DEFAULT_AUTHENTICATED_LOA = 1;

public static final String SESSION_NOTE_LIGHTWEIGHT_USER = "keycloak.userModel";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public static AuthenticationFlowModel resolveDirectGrantFlow(AuthenticationSessi
return authSession.getRealm().getDirectGrantFlow();
}

private static String getRequestedFlowAlias(AuthenticationSessionModel authSession) {
public static String getRequestedFlowAlias(AuthenticationSessionModel authSession) {
// Prefer the client note; fall back to the auth note for logins started on an older server.
String requestedFlowAlias = authSession.getClientNote(Constants.REQUESTED_AUTHENTICATION_FLOW);
return requestedFlowAlias != null ? requestedFlowAlias : authSession.getAuthNote(Constants.REQUESTED_AUTHENTICATION_FLOW);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.authentication.authenticators.client.ClientAuthUtil;
import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
Expand Down Expand Up @@ -1236,8 +1235,6 @@ public void validateUser(UserModel authenticatedUser) {
}

protected Response authenticationComplete() {
new AcrStore(session, authenticationSession).setAuthFlowLevelAuthenticatedToCurrentRequest();

// attachSession(); // Session will be attached after requiredActions + consents are finished.
AuthenticationManager.setClientScopesInSession(session, authenticationSession);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;

import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator;
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.authentication.authenticators.util.AuthenticatorUtils;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
Expand Down Expand Up @@ -262,6 +265,10 @@ private Response createSelectAuthenticatorsScreen(AuthenticationExecutionModel e
@Override
public Response processFlow() {
logger.debugf("processFlow: %s", flow.getAlias());

if (flow.isTopLevel()) {
registerForcedLevelOfAuthenticationCheck();
}
String selector = processor.getAuthenticationSession().getAuthNote(AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED);
if (selector != null) {
String lastExecutionId = processor.getAuthenticationSession().getAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
Expand Down Expand Up @@ -319,14 +326,15 @@ public Response processFlow() {
if (response != null) {
return response;
}
if (processor.isSuccessful(alternative) || isSetupRequired(alternative)) {
return onFlowExecutionsSuccessful();
}
} catch (AuthenticationFlowException afe) {
//consuming the error is not good here from an administrative point of view, but the user, since he has alternatives, should be able to go to another alternative and continue
afeList.add(afe);
setExecutionStatus(alternative, AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
}
// outside the try block, so that errors from top flow callbacks are not consumed as errors of the alternative
if (processor.isSuccessful(alternative) || isSetupRequired(alternative)) {
return onFlowExecutionsSuccessful();
}
}
} else {
if (requiredElementsSuccessful) {
Expand Down Expand Up @@ -620,11 +628,29 @@ private void checkAuthCallback(AuthenticationExecutionModel execution) {
}
}

/**
* The forced level is checked by the callback of the "Condition - Level of Authentication" authenticator, which is otherwise
* registered only once a level condition is evaluated. Register it up-front, so the check runs even when the flow has no level
* condition or skips all of them, for example because the user has no credential for the requested level.
* While a first broker login is in progress, the authentication continues in the post broker login flow, which can still reach
* the requested level and registers the check on its own, so the intermediate flows do not register it.
*/
private void registerForcedLevelOfAuthenticationCheck() {
AuthenticationSessionModel authSession = processor.getAuthenticationSession();
if (authSession.getAuthNote(AbstractIdpAuthenticator.BROKERED_CONTEXT_NOTE) != null) {
return;
}
if (new AcrStore(processor.getSession(), authSession).isLevelOfAuthenticationForced()) {
AuthenticatorUtil.setAuthCallbacksFactoryIds(authSession, ConditionalLoaAuthenticatorFactory.PROVIDER_ID);
Comment thread
Copilot marked this conversation as resolved.
Resolved
}
}

// This is triggered when current flow is successful due the fact that it's executions passed.
// It is opportunity to do some last "generic" checks before considering whole authentication as successful
private Response onFlowExecutionsSuccessful() {
if (flow.isTopLevel()) {
logger.debugf("Authentication successful of the top flow '%s'", flow.getAlias());
new AcrStore(processor.getSession(), processor.getAuthenticationSession()).setAuthFlowLevelAuthenticatedToCurrentRequest(flow);
executeTopFlowSuccessCallbacks();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ public void authenticate(AuthenticationFlowContext context) {
int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication(topLevelFlowId);
AuthenticatorUtils.updateCompletedExecutions(context.getAuthenticationSession(), authResult.session(), context.getExecution().getId());

if (acrStore.getRequestedLevelOfAuthentication(context.getTopLevelFlow()) > previouslyAuthenticatedLevel) {
// an SSO session satisfies level 0 also when no level was recorded for it
if (acrStore.getRequestedLevelOfAuthentication(context.getTopLevelFlow()) > Math.max(previouslyAuthenticatedLevel, Constants.MINIMUM_LOA)) {
// Step-up authentication, we keep the loa from the existing user session.
// The cookie alone is not enough and other authentications must follow.
acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,14 @@

import org.keycloak.authentication.AuthenticationFlowCallback;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.authenticators.util.AcrNotFulfilledException;
import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.authentication.authenticators.util.LoAUtil;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;

import org.jboss.logging.Logger;
Expand Down Expand Up @@ -109,9 +107,7 @@ public void onTopFlowSuccess(AuthenticationFlowModel topFlow) {

logger.tracef("Finished authentication at level %d when authenticating authSession '%s'.", acrStore.getLevelOfAuthenticationFromCurrentAuthentication(), authSession.getParentSession().getId());
if (acrStore.isLevelOfAuthenticationForced() && !acrStore.isLevelOfAuthenticationSatisfiedFromCurrentAuthentication(topFlow)) {
String details = String.format("Forced level of authentication did not meet the requirements. Requested level: %d, Fulfilled level: %d",
acrStore.getRequestedLevelOfAuthentication(topFlow), acrStore.getLevelOfAuthenticationFromCurrentAuthentication());
throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, details, Messages.ACR_NOT_FULFILLED);
throw new AcrNotFulfilledException(acrStore.getRequestedLevelOfAuthentication(topFlow), acrStore.getEffectiveLevelOfAuthentication());
}

logger.tracef("Updating authenticated levels in authSession '%s' to user session note for future authentications: %s", authSession.getParentSession().getId(), authSession.getAuthNote(Constants.LOA_MAP));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.keycloak.authentication.authenticators.util;

import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.services.messages.Messages;

/**
* Thrown when an authentication finishes without reaching a forced level of authentication.
*/
public class AcrNotFulfilledException extends AuthenticationFlowException {

public AcrNotFulfilledException(int requestedLevel, int fulfilledLevel) {
super(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR,
String.format("Forced level of authentication did not meet the requirements. Requested level: %d, Fulfilled level: %d", requestedLevel, fulfilledLevel),
Messages.ACR_NOT_FULFILLED);
}
}
Loading
Loading
0