8000 Add commit-time max_auth_age check for application-initiated actions by msdaly200 · Pull Request #54551 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,11 @@ public void validateUser(UserModel authenticatedUser) {

protected Response authenticationComplete() {
// attachSession(); // Session will be attached after requiredActions + consents are finished.
// Do not record an auth time for SSO (cookie only) authentication, as no credentials were presented in this flow.
// The check for max_auth_age of application-initiated actions then falls back to the auth time of the user session.
if (!AuthenticatorUtil.isSSOAuthentication(authenticationSession)) {
authenticationSession.setAuthNote(AuthenticationManager.AUTH_TIME, String.valueOf(Time.currentTime()));
}
AuthenticationManager.setClientScopesInSession(session, authenticationSession);

String nextRequiredAction = nextRequiredAction();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.keycloak.authentication.actiontoken.DefaultActionToken;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.common.util.reflections.Types;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialProviderFactory;
Expand All @@ -44,9 +45,12 @@
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.DefaultRequiredActions;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
Expand Down Expand Up @@ -271,4 +275,98 @@ public static void addAuthCredential(AuthenticationSessionModel authSession, Str
throw new RuntimeException(e);
}
}

/**
* Resolves the user session whose auth time is used by the {@code max_auth_age} check of an application-initiated action.
* <p>
* The user session is looked up by the ID of the root authentication session first. When the root authentication session was created with
* its own ID (e.g. restart of the login or an action token with an existing SSO session), no user session with that ID exists. In that case
* this falls back to the session of the identity cookie, which is the session {@code CookieAuthenticator} used when it authenticated the
* user via SSO. Without the fallback the auth time would be missing and the re-authentication check would fire on every submit.
*
* @param session the Keycloak session
* @param authSession the current authentication session
* @return the user session, or {@code null} if none can be resolved
*/
public static UserSessionModel getUserSessionForKcAction(KeycloakSession session, AuthenticationSessionModel authSession) {
UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession);
if (userSession != null) {
return userSession;
}
AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie(session, authSession.getRealm(), true);
if (authResult == null || authResult.session() == null) {
return null;
}
UserModel authenticatedUser = authSession.getAuthenticatedUser();
if (authenticatedUser != null && !authenticatedUser.getId().equals(authResult.user().getId())) {
return null;
}
return authResult.session();
}

/**
* Checks if re-authentication is required for an application-initiated action (kc_action).
* @param session the Keycloak session
* @param userSession the current user session, or {@code null} if it does not exist (e.g. expired or removed). In that case only the
* auth-session timestamp is considered and a missing timestamp results in re-authentication being required
* @param authSession the current authentication session, or {@code null}. It must be the authentication session of
* {@code session.getContext()}: the default {@link RequiredActionProvider#getMaxAuthAge(KeycloakSession)}
* resolves the max auth age configuration of the action from the context's authentication session
* @param provider the required action provider instance, or {@code null} to resolve it from {@code kc_action}
* @param actionId the expected action ID to match against {@code kc_action}, or {@code null} to check any action
* @return {@code true} if re-authentication is required because the user's auth time exceeds the action's max auth age, {@code false} otherwise
*/
public static boolean isReAuthRequiredForKcAction(KeycloakSession session, UserSessionModel userSession, AuthenticationSessionModel authSession, RequiredActionProvider provider, String actionId) {
if (authSession == null) {
return false;
}
String kcAction = authSession.getClientNote(Constants.KC_ACTION);
if (kcAction == null) {
return false;
}

if (actionId != null) {
String normalizedKcAction = DefaultRequiredActions.getDefaultRequiredActionCaseInsensitively(kcAction);
String normalizedActionId = DefaultRequiredActions.getDefaultRequiredActionCaseInsensitively(actionId);

if (!normalizedKcAction.equalsIgnoreCase(normalizedActionId)) {
return false;
}
}

if (provider == null) {
String providerId = null;
String resolvedAction = DefaultRequiredActions.getDefaultRequiredActionCaseInsensitively(kcAction);
if (session.getContext() != null && session.getContext().getRealm() != null) {
RequiredActionProviderModel model = session.getContext().getRealm().getRequiredActionProviderByAlias(resolvedAction);
if (model != null) {
providerId = model.getProviderId();
}
}
if (providerId == null) {
providerId = resolvedAction;
}
provider = session.getProvider(RequiredActionProvider.class, providerId);
if (provider == null) {
return false;
}
}
int maxAuthAge = provider.getMaxAuthAge(session);

String authTimeStr = authSession.getAuthNote(AuthenticationManager.AUTH_TIME);
boolean isAuthSessionNote = true;
if (authTimeStr == null) {
authTimeStr = userSession == null ? null : userSession.getNote(AuthenticationManager.AUTH_TIME);
isAuthSessionNote = false;
}

if (maxAuthAge == 0) {
return !isAuthSessionNote;
} else if (maxAuthAge < 0) {
maxAuthAge = Constants.KC_ACTION_MAX_AGE;
}

long authTime = authTimeStr == null ? 0 : Long.parseLong(authTimeStr);
return authTime + maxAuthAge < Time.currentTime();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
import org.keycloak.OAuthErrorException;
import org.keycloak.TokenIdGenerator;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.connections.httpclient.HttpClientProvider;
Expand Down Expand Up @@ -75,6 +74,7 @@

import org.jboss.logging.Logger;

import static org.keycloak.authentication.AuthenticatorUtil.isReAuthRequiredForKcAction;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.approveOAuth2DeviceAuthorization;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.denyOAuth2DeviceAuthorization;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.isOAuth2DeviceVerificationFlow;
Expand Down Expand Up @@ -547,7 +547,9 @@ public Response finishBrowserLogout(UserSessionModel userSession, Authentication

@Override
public boolean requireReauthentication(UserSessionModel userSession, AuthenticationSessionModel authSession) {
return isPromptLogin(authSession) || isAuthTimeExpired(userSession, authSession) || isReAuthRequiredForKcAction(userSession, authSession);
// Without a user session (e.g. SAMLIdentityProvider) there is no authentication time to compare, so the kc_action max_auth_age check does not apply
return isPromptLogin(authSession) || isAuthTimeExpired(userSession, authSession)
|| (userSession != null && isReAuthRequiredForKcAction(session, userSession, authSession, null, null));
}

protected boolean isPromptLogin(AuthenticationSessionModel authSession) {
Expand Down Expand Up @@ -577,22 +579,6 @@ protected boolean isAuthTimeExpired(UserSessionModel userSession, Authentication
return false;
}

protected boolean isReAuthRequiredForKcAction(UserSessionModel userSession, AuthenticationSessionModel authSession) {
if (userSession != null && authSession.getClientNote(Constants.KC_ACTION) != null) {
String providerId = authSession.getClientNote(Constants.KC_ACTION);
RequiredActionProvider requiredActionProvider = this.session.getProvider(RequiredActionProvider.class, providerId);
if (requiredActionProvider == null) {
return false;
}
String authTime = userSession.getNote(AuthenticationManager.AUTH_TIME);
int authTimeInt = authTime == null ? 0 : Integer.parseInt(authTime);
int maxAgeInt = requiredActionProvider.getMaxAuthAge(session);
return (long) authTimeInt + maxAgeInt < Time.currentTime();
} else {
return false;
}
}

@Override
public boolean sendPushRevocationPolicyRequest(RealmModel realm, ClientModel resource, int notBefore, String managementUrl) {
PushNotBeforeAction adminAction = new PushNotBeforeAction(TokenIdGenerator.generateId(), Time.currentTime() + 30, resource.getClientId(), notBefore);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
import org.keycloak.authentication.authenticators.broker.IdpConfirmOverrideLinkAuthenticator;
Expand Down Expand Up @@ -1114,6 +1115,16 @@ private Response finishBrokerAuthentication(BrokeredIdentityContext context, Use
logger.debugf("Performing local authentication for user [%s].", federatedUser);
}

// Record the authentication time (as AuthenticationProcessor does for other flows), so the max_auth_age check of
// application-initiated actions can be done when the action is committed. The upstream broker timestamp is preferred
// when available (OIDC auth_time with "Pass max_age"), otherwise the current time is used.
// SSO (cookie only) logins are not credentials presented.
if (!AuthenticatorUtil.isSSOAuthentication(authSession)) {
String authTime = Optional.ofNullable(authSession.getClientNote(AuthenticationManager.AUTH_TIME_BROKER))
.orElse(String.valueOf(Time.currentTime()));
authSession.setAuthNote(AuthenticationManager.AUTH_TIME, authTime);
}

AuthenticationManager.setClientScopesInSession(session, authSession);

String nextRequiredAction = AuthenticationManager.nextRequiredAction(session, authSession, request, event);
Expand Down Expand Up @@ -1225,6 +1236,16 @@ private Response performAccountLinking(AuthenticationSessionModel authSession, B
return redirectToErrorWhenLinkingFailed(authSession, Errors.NOT_ALLOWED, Messages.FEDERATED_IDENTITY_BOUND_ORGANIZATION);
}

UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession);

// The external IdP round trip may have outlasted max_auth_age of the idp_link action. Check it before the link is persisted,
// so that a rejected action never leaves a committed link behind. The completion call of the action skips this check.
if (Boolean.parseBoolean(authSession.getAuthNote(IdpLinkAction.KC_ACTION_LINKING_IDENTITY_PROVIDER))
&& AuthenticatorUtil.isReAuthRequiredForKcAction(session, userSession != null ? userSession : AuthenticatorUtil.getUserSessionForKcAction(session, authSession), authSession, null, IdpLinkAction.PROVIDER_ID)) {
logger.debugf("Re-authentication required before linking identity provider [%s] to user [%s]", context.getIdpConfig().getAlias(), authenticatedUser.getUsername());
return new LoginActionsService(session, event).resetFlowAndRedirectToAuthenticate(authSession);
}

if (federatedUser != null) {
if (Booleans.isTrue(context.getIdpConfig().isStoreToken())) {
FederatedIdentityModel oldModel = this.session.users().getFederatedIdentity(this.realmModel, federatedUser, context.getIdpConfig().getAlias());
Expand Down Expand Up @@ -1258,7 +1279,6 @@ private Response performAccountLinking(AuthenticationSessionModel authSession, B
// we do this to make sure that the parent IDP is logged out when this user session is complete.
// But for the case when userSession was previously authenticated with broker1 and now is linked to another broker2, we shouldn't override broker1 notes with the broker2 for sure.
// Maybe broker logout should be rather always skiped in case of broker-linking
UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession);
if (userSession != null && userSession.getNote(Details.IDENTITY_PROVIDER) == null) {
userSession.setNote(Details.IDENTITY_PROVIDER, context.getIdpConfig().getAlias());
userSession.setNote(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername());
Expand Down
Loading
Loading
0