Skip to content

Commit

Permalink
Initial mfa implementation for OIDC and SAML IdP
Browse files Browse the repository at this point in the history
  • Loading branch information
cgeorgilakis-grnet committed Jan 16, 2025
1 parent d5cb1da commit 21f3494
Show file tree
Hide file tree
Showing 10 changed files with 119 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,7 @@
"logoUri": "Logo URI for showing in login and linked account page",
"perEntityIds": "IdPs that will be parsed",
"perRegistrationAuthority": "IdPs with these registration authorities will be parsed",
"perEntityCategory":"Entity categories that will be parsed"
"perEntityCategory":"Entity categories that will be parsed",
"passSetMfa": "Pass MFA requested by client to IdP. If IdP successfully respond with specific MFA, set this value as level-of-authentication.",
"claimsParameterSupported": "For MFA. If it is true, acr claim request with essential false will be sent. Otherwise, acr_values parameter will be sent."
}
4 changes: 3 additions & 1 deletion js/apps/admin-ui/public/locales/en/identity-providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,7 @@
"autoUpdate":"Auto Update",
"deletedSuccessIdentityProvider": "Identity provider successfully deleted.",
"deleteErrorIdentityProvider": "Could not delete the identity provider {{error}}",
"empty": "<EMPTY>"
"empty": "<EMPTY>",
"passSetMfa": "Pass and set MFA",
"claimsParameterSupported": "Claims Parameter Supported"
}
20 changes: 20 additions & 0 deletions js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
defaultValue: "false",
});
const claimFilterRequired = filteredByClaim === "true";
const passSetMfa = useWatch({
control,

Check failure on line 114 in js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Delete `··`
name: "config.passSetMfa",

Check failure on line 115 in js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Delete `··`
defaultValue: "false",

Check failure on line 116 in js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Replace `······` with `····`
});

Check failure on line 117 in js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Delete `··`
const claimsParameterSupportedRequired = passSetMfa === "true";

Check failure on line 118 in js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Delete `··`
return (
<>
{!isOIDC && !isSAML && (
Expand Down Expand Up @@ -233,6 +239,20 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
</FormGroup>
</>
)}
{(isSAML || isOIDC) && (
<SwitchField
field="passSetMfa"
label="passSetMfa"
fieldType="boolean"
/>
)}
{isOIDC && claimsParameterSupportedRequired && (
<SwitchField
field="claimsParameterSupported"
label="claimsParameterSupported"
fieldType="boolean"
/>
)}
<LoginFlow
field="firstBrokerLoginFlowAlias"
label="firstBrokerLoginFlowAlias"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public class IdentityProviderModel implements Serializable {
public static final String LAST_REFRESH_TIME = "lastRefreshTime";
public static final String LOGO_URI = "logoUri";

public static final String PASS_SET_MFA = "passSetMfa";

private String internalId;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,10 @@ protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) {
uriBuilder.queryParam(OAuth2Constants.PROMPT, prompt);
}

String acr = request.getAuthenticationSession().getClientNote(OAuth2Constants.ACR_VALUES);
if (acr != null) {
uriBuilder.queryParam(OAuth2Constants.ACR_VALUES, acr);
}
// String acr = request.getAuthenticationSession().getClientNote(OAuth2Constants.ACR_VALUES);
// if (acr != null) {
// uriBuilder.queryParam(OAuth2Constants.ACR_VALUES, acr);
// }
String forwardParameterConfig = getConfig().getForwardParameters() != null ? getConfig().getForwardParameters(): "";
List<String> forwardParameters = Arrays.asList(forwardParameterConfig.split("\\s*,\\s*"));
for(String forwardParameter: forwardParameters) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.broker.provider.BrokeredIdentityContext;
Expand All @@ -48,13 +49,16 @@
import org.keycloak.keys.loader.PublicKeyStorageManager;
import org.keycloak.models.AbstractKeycloakTransaction;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.AcrUtils;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.ClaimsRepresentation;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPage;
Expand All @@ -79,11 +83,16 @@
import jakarta.ws.rs.core.UriInfo;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;

Expand Down Expand Up @@ -438,6 +447,15 @@ public BrokeredIdentityContext getFederatedIdentity(String response) {
if (!getConfig().isDisableNonce()) {
identity.getContextData().put(BROKER_NONCE_PARAM, idToken.getOtherClaims().get(OIDCLoginProtocol.NONCE_PARAM));
}
if (getConfig().isPassSetMfa() && idToken.getOtherClaims().get(IDToken.ACR) != null) {
AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession();
Integer idpLoa = AcrUtils.getAcrLoaMap(authenticationSession.getClient()).get(idToken.getOtherClaims().get(IDToken.ACR));
AcrStore acrStore = new AcrStore(session, authenticationSession);
//set idp acr loa only if it is higher than current loa
if (idpLoa != null && idpLoa > acrStore.getLevelOfAuthenticationFromCurrentAuthentication()){
acrStore.setLevelAuthenticated(idpLoa);
}
}

if (getConfig().isStoreToken()) {
if (tokenResponse.getExpiresIn() > 0) {
Expand Down Expand Up @@ -949,6 +967,28 @@ protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) {
uriBuilder.queryParam(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge);
}

String requestedLoa = request.getAuthenticationSession().getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION);
if (getConfig().isPassSetMfa() && requestedLoa != null) {
Map.Entry<String, Integer> loaEntry = AcrUtils.getAcrLoaMap(request.getAuthenticationSession().getClient()).entrySet().stream().filter(entry -> entry.getValue().equals(Integer.valueOf(requestedLoa))).findFirst().get();
if (loaEntry != null && getConfig().isClaimsParameterSupported()) {
ClaimsRepresentation claims = new ClaimsRepresentation();
Map<String, ClaimsRepresentation.ClaimValue> claimValueMap = new HashMap<>();
ClaimsRepresentation.ClaimValue claimValue = new ClaimsRepresentation.ClaimValue();
claimValue.setEssential(Boolean.FALSE);
List<String> claimValues = Stream.of(loaEntry.getKey()).collect(Collectors.toList());
claimValue.setValues(claimValues);
claimValueMap.put(IDToken.ACR, claimValue);
claims.setIdTokenClaims(claimValueMap);
try {
uriBuilder.queryParam(OIDCLoginProtocol.CLAIMS_PARAM, URLEncoder.encode(JsonSerialization.writeValueAsString(claims), "UTF-8"));
} catch (IOException e) {
throw new RuntimeException(e);
}
} else if (loaEntry != null ) {
uriBuilder.queryParam(OIDCLoginProtocol.ACR_PARAM, loaEntry.getKey());
}
}

return uriBuilder;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
public static final String IS_ACCESS_TOKEN_JWT = "isAccessTokenJWT";
public static final String TOKEN_INTROSPECTION_URL = "tokenIntrospectionUrl";
public static final String VALIDATE_REFRESH_TOKEN = "validateRefreshToken";
public static final String CLAIMS_PARAMETER_SUPPORTED = "claimsParameterSupported";

public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel);
Expand Down Expand Up @@ -201,6 +202,21 @@ public void setValidateRefreshToken(boolean validateRefreshToken) {
getConfig().put(VALIDATE_REFRESH_TOKEN, String.valueOf(validateRefreshToken));
}

public void setPassSetMfa(boolean passSetMfa) {
getConfig().put(IdentityProviderModel.PASS_SET_MFA, String.valueOf(passSetMfa));
}

public boolean isPassSetMfa() {
return Boolean.valueOf(getConfig().get(IdentityProviderModel.PASS_SET_MFA));
}

public void setClaimsParameterSupported(boolean claimsParameterSupported) {
getConfig().put(CLAIMS_PARAMETER_SUPPORTED, String.valueOf(claimsParameterSupported));
}

public boolean isClaimsParameterSupported() {
return Boolean.valueOf(getConfig().get(CLAIMS_PARAMETER_SUPPORTED));
}

@Override
public void validate(RealmModel realm) {
Expand Down
13 changes: 13 additions & 0 deletions services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;

import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.IdentityProvider;
Expand Down Expand Up @@ -48,6 +49,7 @@
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.protocol.oidc.utils.AcrUtils;
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.protocol.saml.SamlProtocolUtils;
Expand All @@ -56,6 +58,7 @@
import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor;
import org.keycloak.protocol.saml.SAMLDecryptionKeysLocator;
import org.keycloak.representations.AuthnAuthorityRepresentation;
import org.keycloak.representations.IDToken;
import org.keycloak.saml.SAML2LogoutResponseBuilder;
import org.keycloak.saml.SAMLRequestParser;
import org.keycloak.saml.common.constants.GeneralConstants;
Expand Down Expand Up @@ -642,6 +645,16 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h
authSession.setUserSessionNote(Details.IDENTITY_PROVIDER_AUTHN_AUTHORITIES, JsonSerialization.writeValueAsString(authn.getAuthnContext().getAuthenticatingAuthority().stream().map(x -> new AuthnAuthorityRepresentation(x.toString())).toList()));
}

//set loa if it configured and IdP return appropriate value
if (config.isPassSetMfa() && authn.getAuthnContext() != null && authn.getAuthnContext().getSequence() != null && authn.getAuthnContext().getSequence().getClassRef() != null ) {
Integer idpLoa = AcrUtils.getAcrLoaMap(authSession.getClient()).get(authn.getAuthnContext().getSequence().getClassRef().getValue());
AcrStore acrStore = new AcrStore(session, authSession);
//set idp acr loa only if it is higher than current loa
if (idpLoa != null && idpLoa > acrStore.getLevelOfAuthenticationFromCurrentAuthentication()){
acrStore.setLevelAuthenticated(idpLoa);
}
}

return callback.authenticated(identity);
} catch (WebApplicationException e) {
return e.getResponse();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
import org.keycloak.dom.saml.v2.protocol.ResponseType;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.Constants;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.KeyManager;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.AcrUtils;
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.protocol.saml.SamlService;
Expand Down Expand Up @@ -147,6 +149,14 @@ public Response performLogin(AuthenticationRequest request) {
for (String authnContextDeclRef : getAuthnContextDeclRefUris())
requestedAuthnContext.addAuthnContextDeclRef(authnContextDeclRef);

String requestedLoa = request.getAuthenticationSession().getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION);
if (getConfig().isPassSetMfa() && requestedLoa != null) {
Entry<String, Integer> loaEntry = AcrUtils.getAcrLoaMap(request.getAuthenticationSession().getClient()).entrySet().stream().filter(entry -> entry.getValue().equals(Integer.valueOf(requestedLoa))).findFirst().get();
if (loaEntry != null) {
requestedAuthnContext.addAuthnContextClassRef(loaEntry.getKey());
}
}

Integer attributeConsumingServiceIndex = getConfig().isOmitAttributeConsumingServiceIndexAuthn() ? null : getConfig().getAttributeConsumingServiceIndex();

String loginHint = getConfig().isLoginHint() ? request.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,14 @@ public void setLastRefreshTime(long lastRefreshTime) {
getConfig().put(LAST_REFRESH_TIME, String.valueOf(lastRefreshTime));
}

public void setPassSetMfa(boolean passSetMfa) {
getConfig().put(IdentityProviderModel.PASS_SET_MFA, String.valueOf(passSetMfa));
}

public boolean isPassSetMfa() {
return Boolean.valueOf(getConfig().get(IdentityProviderModel.PASS_SET_MFA));
}

@Override
public void validate(RealmModel realm) {
super.validate(realm);
Expand Down

0 comments on commit 21f3494

Please sign in to comment.