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