From 2e5d2f037e6619454c05cc74afdca7aa4b6b626f Mon Sep 17 00:00:00 2001 From: Andrey Rudenko Date: Mon, 11 Nov 2024 10:48:45 +0100 Subject: [PATCH 01/11] MS custom idp & more custom execution steps --- .../NeonCleanUnverifiedAuthenticator.java | 69 ++++++++++++ ...onCleanUnverifiedAuthenticatorFactory.java | 76 +++++++++++++ .../custom/NeonIdpCreateUserIfUnique.java | 82 ++++++++++++++ .../NeonIdpCreateUserIfUniqueFactory.java | 79 ++++++++++++++ .../NeonIdpEmailVerifyAuthenticator.java | 59 ++++++++++ ...eonIdpEmailVerifyAuthenticatorFactory.java | 79 ++++++++++++++ .../microsoft/MicrosoftIdentityProvider.java | 103 ++++++++++++++++++ .../MicrosoftIdentityProviderFactory.java | 33 ++++++ .../MicrosoftUserAttributeMapper.java | 19 ++++ ...ycloak.authentication.AuthenticatorFactory | 5 +- ...oak.broker.provider.IdentityProviderMapper | 1 + ...roker.social.SocialIdentityProviderFactory | 1 + 12 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java create mode 100644 src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticatorFactory.java create mode 100644 src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java create mode 100644 src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java create mode 100644 src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java create mode 100644 src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java create mode 100644 src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java create mode 100644 src/main/java/tech/neon/microsoft/MicrosoftIdentityProviderFactory.java create mode 100644 src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java diff --git a/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java b/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java new file mode 100644 index 0000000..7cb82ee --- /dev/null +++ b/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java @@ -0,0 +1,69 @@ +package tech.neon.custom; + +import java.util.stream.Stream; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; +import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.SubjectCredentialManager; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.sessions.AuthenticationSessionModel; + +public class NeonCleanUnverifiedAuthenticator extends AbstractIdpAuthenticator { + private static Logger logger = Logger.getLogger(NeonCleanUnverifiedAuthenticator.class); + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return false; + } + + @Override + protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + + UserModel user = getExistingUser(session, realm, authSession); + + if (user.isEmailVerified()) { + logger.debug("User " + user.getUsername() + " is already verified, skipping cleanup"); + context.success(); + return; + } + + logger.debug("Cleaning up unverified user: " + user.getUsername()); + SubjectCredentialManager manager = user.credentialManager(); + + manager.getStoredCredentialsByTypeStream(PasswordCredentialModel.TYPE) + .forEach(c -> { + logger.debug("Removing credential: " + c.getId() + " for user: " + user.getUsername()); + manager.removeStoredCredentialById(c.getId()); + }); + + Stream linkedAccounts = session.users().getFederatedIdentitiesStream(realm, user); + + linkedAccounts.forEach(identity -> { + logger.debug("Removing federated identity: " + identity.getIdentityProvider() + " for user: " + user.getUsername()); + session.users().removeFederatedIdentity(realm, user, identity.getIdentityProvider()); + }); + + context.success(); + } + + @Override + protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + + } +} diff --git a/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticatorFactory.java b/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticatorFactory.java new file mode 100644 index 0000000..12b71b3 --- /dev/null +++ b/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticatorFactory.java @@ -0,0 +1,76 @@ +package tech.neon.custom; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +public class NeonCleanUnverifiedAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "neon-clean-unverified"; + static NeonCleanUnverifiedAuthenticator SINGLETON = new NeonCleanUnverifiedAuthenticator(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED + }; + } + + @Override + public String getDisplayType() { + return "Clean Unverified User"; + } + + @Override + public String getHelpText() { + return "Cleans up unverified user data by removing passwords and social links"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java new file mode 100644 index 0000000..b5547c4 --- /dev/null +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java @@ -0,0 +1,82 @@ +package tech.neon.custom; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; +import org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticator; +import org.keycloak.authentication.authenticators.broker.util.ExistingUserInfo; +import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import java.util.List; +import java.util.Map; + +import org.jboss.logging.Logger; + +public class NeonIdpCreateUserIfUnique extends AbstractIdpAuthenticator { + + private static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class); + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return false; + } + + @Override + protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + + String email = brokerContext.getEmail(); + + UserModel existingUser = context.getSession().users().getUserByEmail(context.getRealm(), email); + + if (existingUser == null) { + logger.debugf( + "No duplication detected. Creating account for user '%s' and linking with identity provider '%s' .", + email, brokerContext.getIdpConfig().getAlias()); + + UserModel federatedUser = session.users().addUser(realm, email); + federatedUser.setEnabled(true); + + if (Boolean.TRUE.equals(brokerContext.getContextData().get(NeonIdpEmailVerifyAuthenticator.VERIFIED_EMAIL))) { + federatedUser.setEmailVerified(true); + logger.debug("Email verified successfully for user: " + federatedUser.getEmail()); + + } + + for (Map.Entry> attr : serializedCtx.getAttributes().entrySet().stream() + .sorted(Map.Entry.comparingByKey()).toList()) { + if (!UserModel.USERNAME.equalsIgnoreCase(attr.getKey())) { + federatedUser.setAttribute(attr.getKey(), attr.getValue()); + } + } + + context.setUser(federatedUser); + context.getAuthenticationSession().setAuthNote(BROKER_REGISTERED_NEW_USER, "true"); + context.success(); + } else { + ExistingUserInfo duplication = new ExistingUserInfo(existingUser.getId(), UserModel.EMAIL, existingUser.getEmail()); + logger.debugf("Duplication detected. There is already existing user with %s '%s' .", + duplication.getDuplicateAttributeName(), duplication.getDuplicateAttributeValue()); + + // Set duplicated user, so next authenticators can deal with it + context.getAuthenticationSession().setAuthNote(EXISTING_USER_INFO, duplication.serialize()); + context.attempted(); + } + } + + @Override + protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + } + +} \ No newline at end of file diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java new file mode 100644 index 0000000..3cfee37 --- /dev/null +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java @@ -0,0 +1,79 @@ +package tech.neon.custom; + +import java.util.List; + +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.Config; + + +public class NeonIdpCreateUserIfUniqueFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "neon-idp-create-user-if-unique"; + static NeonIdpCreateUserIfUnique SINGLETON = new NeonIdpCreateUserIfUnique(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getDisplayType() { + return "Create User If Unique"; + } + + @Override + public String getHelpText() { + return "Create User If Unique"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } +} diff --git a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java new file mode 100644 index 0000000..01aaa62 --- /dev/null +++ b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java @@ -0,0 +1,59 @@ +package tech.neon.custom; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; +import org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticator; +import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.sessions.AuthenticationSessionModel; + +import org.jboss.logging.Logger; + +public class NeonIdpEmailVerifyAuthenticator extends AbstractIdpAuthenticator { + public static final String VERIFIED_EMAIL = "VERIFIED_EMAIL"; + private static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class); + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return false; + } + + @Override + protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + logger.debug("Starting email verification authentication for user: " + brokerContext.getEmail()); + + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + + if (brokerContext.getIdpConfig().isTrustEmail() + || Boolean.TRUE.equals(brokerContext.getContextData().get(VERIFIED_EMAIL))) { + logger.debug("Email is trusted or already verified. Proceeding with authentication."); + + UserModel user = getExistingUser(session, realm, authSession); + user.setEmailVerified(true); + logger.debug("Email verified successfully for user: " + user.getEmail()); + context.success(); + + } else { + logger.debug("Email verification attempted but not trusted/verified for: " + brokerContext.getEmail()); + context.attempted(); + } + } + + @Override + protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + logger.warn("Action implementation called for email verification"); + } + +} \ No newline at end of file diff --git a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java new file mode 100644 index 0000000..b2d87a6 --- /dev/null +++ b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java @@ -0,0 +1,79 @@ +package tech.neon.custom; + +import java.util.List; + +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.Config; + + +public class NeonIdpEmailVerifyAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "neon-idp-verify-email"; + static NeonIdpEmailVerifyAuthenticator SINGLETON = new NeonIdpEmailVerifyAuthenticator(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getDisplayType() { + return "Success flow for verified email"; + } + + @Override + public String getHelpText() { + return "Success flow for verified email"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } +} diff --git a/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java new file mode 100644 index 0000000..031f8b3 --- /dev/null +++ b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java @@ -0,0 +1,103 @@ +package tech.neon.microsoft; + +import tech.neon.custom.NeonIdpEmailVerifyAuthenticator; + +import org.jboss.logging.Logger; +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.Map; + +public class MicrosoftIdentityProvider extends OIDCIdentityProvider + implements SocialIdentityProvider { + + private static final String AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; // authorization + // code + // endpoint + private static final String TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; // token + // endpoint + private static final String DEFAULT_SCOPE = "openid profile email User.read"; // the User.read scope should be + // sufficient to obtain all necessary + // user info + + private static final Logger logger = Logger.getLogger(MicrosoftIdentityProvider.class); + + public MicrosoftIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { + super(session, config); + + config.setAuthorizationUrl(AUTH_URL); + config.setTokenUrl(TOKEN_URL); + } + + @Override + protected String getDefaultScopes() { + return DEFAULT_SCOPE; + } + + @Override + public BrokeredIdentityContext getFederatedIdentity(String response) { + + AccessTokenResponse tokenResponse = null; + try { + tokenResponse = JsonSerialization.readValue(response, AccessTokenResponse.class); + } catch (IOException e) { + throw new IdentityBrokerException("Could not decode access token response.", e); + } + + String encodedIdToken = tokenResponse.getIdToken(); + + JsonWebToken idToken = validateToken(encodedIdToken); + + Map claims = idToken.getOtherClaims(); + String id = (String) claims.get("oid"); + BrokeredIdentityContext identity = new BrokeredIdentityContext(id, getConfig()); + + String email = (String) claims.get("email"); + + if (email != null) { + identity.setEmail(email); + identity.getContextData().put(NeonIdpEmailVerifyAuthenticator.VERIFIED_EMAIL, true); + logger.debug("Using verified email: " + email); + } else { + String upnEmail = (String) claims.get("upn"); + logger.debug("Email not found in claims, using UPN instead: " + upnEmail); + identity.setEmail(upnEmail); + } + identity.setUsername(id); + + identity.getContextData().put(VALIDATED_ID_TOKEN, idToken); + + identity.setFirstName((String) claims.get("given_name")); + identity.setLastName((String) claims.get("family_name")); + + identity.getContextData().put(FEDERATED_ACCESS_TOKEN_RESPONSE, tokenResponse); + processAccessTokenResponse(identity, tokenResponse); + + identity.getContextData().put("BROKER_NONCE", idToken.getOtherClaims().get(OIDCLoginProtocol.NONCE_PARAM)); + + if (getConfig().isStoreToken()) { + if (tokenResponse.getExpiresIn() > 0) { + long accessTokenExpiration = Time.currentTime() + tokenResponse.getExpiresIn(); + tokenResponse.getOtherClaims().put(ACCESS_TOKEN_EXPIRATION, accessTokenExpiration); + try { + response = JsonSerialization.writeValueAsString(tokenResponse); + } catch (IOException e) { + throw new IdentityBrokerException("JsonSerialization exception", e); + } + } + identity.setToken(response); + } + + return identity; + } +} diff --git a/src/main/java/tech/neon/microsoft/MicrosoftIdentityProviderFactory.java b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProviderFactory.java new file mode 100644 index 0000000..c457753 --- /dev/null +++ b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProviderFactory.java @@ -0,0 +1,33 @@ +package tech.neon.microsoft; + +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.provider.AbstractIdentityProviderFactory; +import org.keycloak.broker.social.SocialIdentityProviderFactory; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.social.microsoft.MicrosoftIdentityProviderConfig; + +public class MicrosoftIdentityProviderFactory extends AbstractIdentityProviderFactory implements SocialIdentityProviderFactory { + + public static final String PROVIDER_ID = "neon-microsoft"; + + @Override + public String getName() { + return "Neon Microsoft"; + } + + @Override + public MicrosoftIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new MicrosoftIdentityProvider(session, new OIDCIdentityProviderConfig(model)); + } + + @Override + public MicrosoftIdentityProviderConfig createConfig() { + return new MicrosoftIdentityProviderConfig(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} \ No newline at end of file diff --git a/src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java b/src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java new file mode 100644 index 0000000..57a1c1c --- /dev/null +++ b/src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java @@ -0,0 +1,19 @@ +package tech.neon.microsoft; + +import org.keycloak.broker.oidc.mappers.UserAttributeMapper; + +public class MicrosoftUserAttributeMapper extends UserAttributeMapper { + + private static final String[] cp = new String[] { MicrosoftIdentityProviderFactory.PROVIDER_ID }; + + @Override + public String[] getCompatibleProviders() { + return cp; + } + + @Override + public String getId() { + return "neonmicrosoft-user-attribute-mapper"; + } + +} diff --git a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index 2dc93ac..62c1bdf 100644 --- a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -1 +1,4 @@ -trust_email.AuthenticatorFactory \ No newline at end of file +trust_email.AuthenticatorFactory +tech.neon.custom.NeonCleanUnverifiedAuthenticatorFactory +tech.neon.custom.NeonIdpEmailVerifyAuthenticatorFactory +tech.neon.custom.NeonIdpCreateUserIfUniqueFactory \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper b/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper index e0e2d8c..85d6523 100755 --- a/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper +++ b/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper @@ -1,2 +1,3 @@ vercel.VercelMPUserAttributeMapper mappers.ProviderUIDToUserSessionNoteMapper +tech.neon.microsoft.MicrosoftUserAttributeMapper diff --git a/src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory b/src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory index cbc8c83..965493d 100644 --- a/src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory +++ b/src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory @@ -1 +1,2 @@ vercel.VercelMPIdentityProviderFactory +tech.neon.microsoft.MicrosoftIdentityProviderFactory From 590129dbab777c90f72e8986e535e6a24e66289a Mon Sep 17 00:00:00 2001 From: Andrew Rudenko Date: Tue, 19 Nov 2024 14:37:08 +0100 Subject: [PATCH 02/11] Update src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Krzysztof Szafrański --- .../tech/neon/custom/NeonCleanUnverifiedAuthenticator.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java b/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java index 7cb82ee..6b57586 100644 --- a/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java +++ b/src/main/java/tech/neon/custom/NeonCleanUnverifiedAuthenticator.java @@ -51,9 +51,7 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro manager.removeStoredCredentialById(c.getId()); }); - Stream linkedAccounts = session.users().getFederatedIdentitiesStream(realm, user); - - linkedAccounts.forEach(identity -> { + session.users().getFederatedIdentitiesStream(realm, user).forEach(identity -> { logger.debug("Removing federated identity: " + identity.getIdentityProvider() + " for user: " + user.getUsername()); session.users().removeFederatedIdentity(realm, user, identity.getIdentityProvider()); }); From eeb5c59ab88938abb147daf2d3ce9749075f0d8d Mon Sep 17 00:00:00 2001 From: Andrew Rudenko Date: Tue, 19 Nov 2024 14:37:24 +0100 Subject: [PATCH 03/11] Update src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Krzysztof Szafrański --- src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java index b5547c4..68c13b6 100644 --- a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java @@ -15,7 +15,7 @@ import org.jboss.logging.Logger; -public class NeonIdpCreateUserIfUnique extends AbstractIdpAuthenticator { +public class NeonIdpCreateUserIfUniqueAuthenticator extends AbstractIdpAuthenticator { private static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class); From d9777007828e12197eb75926b9b6235b8b59b455 Mon Sep 17 00:00:00 2001 From: Andrew Rudenko Date: Tue, 19 Nov 2024 14:43:20 +0100 Subject: [PATCH 04/11] Update src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Krzysztof Szafrański --- .../java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java index 01aaa62..7898a61 100644 --- a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java +++ b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java @@ -12,7 +12,7 @@ import org.jboss.logging.Logger; -public class NeonIdpEmailVerifyAuthenticator extends AbstractIdpAuthenticator { +public class NeonIdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator { public static final String VERIFIED_EMAIL = "VERIFIED_EMAIL"; private static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class); From 96a1ea511edc2a84e8b77a1d05b9e58e17fa0975 Mon Sep 17 00:00:00 2001 From: Andrew Rudenko Date: Tue, 19 Nov 2024 14:43:27 +0100 Subject: [PATCH 05/11] Update src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Krzysztof Szafrański --- .../java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java index 7898a61..a7c679e 100644 --- a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java +++ b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java @@ -14,7 +14,7 @@ public class NeonIdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator { public static final String VERIFIED_EMAIL = "VERIFIED_EMAIL"; - private static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class); + private static Logger logger = Logger.getLogger(NeonIdpEmailVerifyAuthenticator.class); @Override public boolean requiresUser() { From 243a01584dc7281cc68b7875c42e9947fe3f8d79 Mon Sep 17 00:00:00 2001 From: Andrew Rudenko Date: Tue, 19 Nov 2024 14:43:42 +0100 Subject: [PATCH 06/11] Update src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Krzysztof Szafrański --- src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java index 68c13b6..494e151 100644 --- a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java @@ -17,7 +17,7 @@ public class NeonIdpCreateUserIfUniqueAuthenticator extends AbstractIdpAuthenticator { - private static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class); + private static Logger logger = Logger.getLogger(NeonIdpCreateUserIfUnique.class); @Override public boolean requiresUser() { From cf71d5e8967bcf2cfc7a5714014a04327f1e85fb Mon Sep 17 00:00:00 2001 From: Andrew Rudenko Date: Tue, 19 Nov 2024 14:43:47 +0100 Subject: [PATCH 07/11] Update src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Krzysztof Szafrański --- .../java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java b/src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java index 57a1c1c..2f12fed 100644 --- a/src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java +++ b/src/main/java/tech/neon/microsoft/MicrosoftUserAttributeMapper.java @@ -13,7 +13,7 @@ public String[] getCompatibleProviders() { @Override public String getId() { - return "neonmicrosoft-user-attribute-mapper"; + return "neon-microsoft-user-attribute-mapper"; } } From 38afed012f652279b27146b19c6904e386b27d89 Mon Sep 17 00:00:00 2001 From: Andrey Rudenko Date: Tue, 19 Nov 2024 14:48:47 +0100 Subject: [PATCH 08/11] feedback renamings --- ...que.java => NeonIdpCreateUserIfUniqueAuthenticator.java} | 4 ++-- .../tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java | 2 +- ...ator.java => NeonIdpEmailVerificationAuthenticator.java} | 3 +-- .../neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java | 2 +- .../java/tech/neon/microsoft/MicrosoftIdentityProvider.java | 6 +++--- 5 files changed, 8 insertions(+), 9 deletions(-) rename src/main/java/tech/neon/custom/{NeonIdpCreateUserIfUnique.java => NeonIdpCreateUserIfUniqueAuthenticator.java} (97%) rename src/main/java/tech/neon/custom/{NeonIdpEmailVerifyAuthenticator.java => NeonIdpEmailVerificationAuthenticator.java} (95%) diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java similarity index 97% rename from src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java rename to src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java index 494e151..d980ed4 100644 --- a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUnique.java +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java @@ -17,7 +17,7 @@ public class NeonIdpCreateUserIfUniqueAuthenticator extends AbstractIdpAuthenticator { - private static Logger logger = Logger.getLogger(NeonIdpCreateUserIfUnique.class); + private static Logger logger = Logger.getLogger(NeonIdpCreateUserIfUniqueAuthenticator.class); @Override public boolean requiresUser() { @@ -47,7 +47,7 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro UserModel federatedUser = session.users().addUser(realm, email); federatedUser.setEnabled(true); - if (Boolean.TRUE.equals(brokerContext.getContextData().get(NeonIdpEmailVerifyAuthenticator.VERIFIED_EMAIL))) { + if (Boolean.TRUE.equals(brokerContext.getContextData().get(NeonIdpEmailVerificationAuthenticator.VERIFIED_EMAIL))) { federatedUser.setEmailVerified(true); logger.debug("Email verified successfully for user: " + federatedUser.getEmail()); diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java index 3cfee37..2da63b2 100644 --- a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueFactory.java @@ -14,7 +14,7 @@ public class NeonIdpCreateUserIfUniqueFactory implements AuthenticatorFactory { public static final String PROVIDER_ID = "neon-idp-create-user-if-unique"; - static NeonIdpCreateUserIfUnique SINGLETON = new NeonIdpCreateUserIfUnique(); + static NeonIdpCreateUserIfUniqueAuthenticator SINGLETON = new NeonIdpCreateUserIfUniqueAuthenticator(); @Override public Authenticator create(KeycloakSession session) { diff --git a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java b/src/main/java/tech/neon/custom/NeonIdpEmailVerificationAuthenticator.java similarity index 95% rename from src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java rename to src/main/java/tech/neon/custom/NeonIdpEmailVerificationAuthenticator.java index a7c679e..00ef965 100644 --- a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticator.java +++ b/src/main/java/tech/neon/custom/NeonIdpEmailVerificationAuthenticator.java @@ -2,7 +2,6 @@ import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; -import org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticator; import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.models.KeycloakSession; @@ -14,7 +13,7 @@ public class NeonIdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator { public static final String VERIFIED_EMAIL = "VERIFIED_EMAIL"; - private static Logger logger = Logger.getLogger(NeonIdpEmailVerifyAuthenticator.class); + private static Logger logger = Logger.getLogger(NeonIdpEmailVerificationAuthenticator.class); @Override public boolean requiresUser() { diff --git a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java index b2d87a6..803dff1 100644 --- a/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java +++ b/src/main/java/tech/neon/custom/NeonIdpEmailVerifyAuthenticatorFactory.java @@ -14,7 +14,7 @@ public class NeonIdpEmailVerifyAuthenticatorFactory implements AuthenticatorFactory { public static final String PROVIDER_ID = "neon-idp-verify-email"; - static NeonIdpEmailVerifyAuthenticator SINGLETON = new NeonIdpEmailVerifyAuthenticator(); + static NeonIdpEmailVerificationAuthenticator SINGLETON = new NeonIdpEmailVerificationAuthenticator(); @Override public Authenticator create(KeycloakSession session) { diff --git a/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java index 031f8b3..fb11efc 100644 --- a/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java +++ b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java @@ -1,7 +1,5 @@ package tech.neon.microsoft; -import tech.neon.custom.NeonIdpEmailVerifyAuthenticator; - import org.jboss.logging.Logger; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; @@ -15,6 +13,8 @@ import org.keycloak.representations.JsonWebToken; import org.keycloak.util.JsonSerialization; +import tech.neon.custom.NeonIdpEmailVerificationAuthenticator; + import java.io.IOException; import java.util.Map; @@ -66,7 +66,7 @@ public BrokeredIdentityContext getFederatedIdentity(String response) { if (email != null) { identity.setEmail(email); - identity.getContextData().put(NeonIdpEmailVerifyAuthenticator.VERIFIED_EMAIL, true); + identity.getContextData().put(NeonIdpEmailVerificationAuthenticator.VERIFIED_EMAIL, true); logger.debug("Using verified email: " + email); } else { String upnEmail = (String) claims.get("upn"); From eb57bca2b772122706c1557e5aa4e2ea0672639a Mon Sep 17 00:00:00 2001 From: Andrey Rudenko Date: Wed, 20 Nov 2024 11:19:21 +0100 Subject: [PATCH 09/11] feedback --- .../neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java index d980ed4..9d681a8 100644 --- a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java @@ -15,6 +15,11 @@ import org.jboss.logging.Logger; +/** + * Custom implementation of Keycloak's IdP user creation authenticator. + * This authenticator functions similarly to the built-in IdpCreateUserIfUniqueAuthenticator, + * but adds support for the VERIFIED_EMAIL flag set by NeonIdpEmailVerificationAuthenticator. + */ public class NeonIdpCreateUserIfUniqueAuthenticator extends AbstractIdpAuthenticator { private static Logger logger = Logger.getLogger(NeonIdpCreateUserIfUniqueAuthenticator.class); From 2bbc0063af687a5d7e93715ff1a65d19c750d310 Mon Sep 17 00:00:00 2001 From: Andrey Rudenko Date: Tue, 17 Dec 2024 15:50:00 +0100 Subject: [PATCH 10/11] handle unverified email case --- ...eonIdpCreateUserIfUniqueAuthenticator.java | 3 ++- .../microsoft/MicrosoftIdentityProvider.java | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java index 9d681a8..a34407d 100644 --- a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java @@ -55,7 +55,8 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro if (Boolean.TRUE.equals(brokerContext.getContextData().get(NeonIdpEmailVerificationAuthenticator.VERIFIED_EMAIL))) { federatedUser.setEmailVerified(true); logger.debug("Email verified successfully for user: " + federatedUser.getEmail()); - + } else { + federatedUser.addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL); } for (Map.Entry> attr : serializedCtx.getAttributes().entrySet().stream() diff --git a/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java index fb11efc..cbe8da5 100644 --- a/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java +++ b/src/main/java/tech/neon/microsoft/MicrosoftIdentityProvider.java @@ -5,6 +5,7 @@ import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; @@ -13,6 +14,8 @@ import org.keycloak.representations.JsonWebToken; import org.keycloak.util.JsonSerialization; +import com.fasterxml.jackson.databind.JsonNode; + import tech.neon.custom.NeonIdpEmailVerificationAuthenticator; import java.io.IOException; @@ -30,6 +33,7 @@ public class MicrosoftIdentityProvider extends OIDCIdentityProvider // sufficient to obtain all necessary // user info + private static final String PROFILE_URL = "https://graph.microsoft.com/v1.0/me/"; // user profile service endpoint private static final Logger logger = Logger.getLogger(MicrosoftIdentityProvider.class); public MicrosoftIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { @@ -54,6 +58,16 @@ public BrokeredIdentityContext getFederatedIdentity(String response) { throw new IdentityBrokerException("Could not decode access token response.", e); } + JsonNode profile; + try { + profile = SimpleHttp.doGet(PROFILE_URL, session).auth(tokenResponse.getToken()).asJson(); + if (profile.has("error") && !profile.get("error").isNull()) { + throw new IdentityBrokerException("Error in Microsoft Graph API response. Payload: " + profile.toString()); + } + } catch (Exception e) { + throw new IdentityBrokerException("Could not obtain user profile from Microsoft Graph", e); + } + String encodedIdToken = tokenResponse.getIdToken(); JsonWebToken idToken = validateToken(encodedIdToken); @@ -63,15 +77,20 @@ public BrokeredIdentityContext getFederatedIdentity(String response) { BrokeredIdentityContext identity = new BrokeredIdentityContext(id, getConfig()); String email = (String) claims.get("email"); + String profileEmail = getJsonProperty(profile, "mail"); if (email != null) { identity.setEmail(email); identity.getContextData().put(NeonIdpEmailVerificationAuthenticator.VERIFIED_EMAIL, true); logger.debug("Using verified email: " + email); + } else if (profileEmail != null) { + identity.setEmail(profileEmail); + logger.info("Using unverified email: " + profileEmail); } else { String upnEmail = (String) claims.get("upn"); - logger.debug("Email not found in claims, using UPN instead: " + upnEmail); identity.setEmail(upnEmail); + identity.getContextData().put(NeonIdpEmailVerificationAuthenticator.VERIFIED_EMAIL, true); + logger.debug("Email not found in claims and profile, using UPN instead: " + upnEmail); } identity.setUsername(id); From 64105cc31e2e7109d42490391fbcf9919ba12837 Mon Sep 17 00:00:00 2001 From: Andrey Rudenko Date: Wed, 18 Dec 2024 16:04:21 +0100 Subject: [PATCH 11/11] do not add require email --- .../neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java index a34407d..cc229ea 100644 --- a/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java +++ b/src/main/java/tech/neon/custom/NeonIdpCreateUserIfUniqueAuthenticator.java @@ -55,8 +55,6 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro if (Boolean.TRUE.equals(brokerContext.getContextData().get(NeonIdpEmailVerificationAuthenticator.VERIFIED_EMAIL))) { federatedUser.setEmailVerified(true); logger.debug("Email verified successfully for user: " + federatedUser.getEmail()); - } else { - federatedUser.addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL); } for (Map.Entry> attr : serializedCtx.getAttributes().entrySet().stream()