Custom ResetCredentialEmail does not work after upgrade to Keycloak 21

Hello, our organisation’s keycloak forgot password flow has been customised so that when returning to the login page after having correctly sent the email, a message containing the operation performed and the address to which the email was sent is inserted in the context, in order to show a resume page rather than the classic red message below the username field of the login.

To do this, a jar was created containing a subclass of ResetCredentialEmail called CustomResetCredentialEmail and the related Factory class, CustomResetCredentialEmailFactory which implements AuthenticatorFactory, ConfigurableAuthenticatorFactory.

public class CustomResetCredentialEmail extends ResetCredentialEmail {

    private static final Logger logger = Logger.getLogger(CustomResetCredentialEmail.class);
    private static final String FORGOT_PASSWORD_EMAIL_SENT = "forgot-password-email-sent";
    private static final String FORGOT_PASSWORD_EMAIL_SEPARATOR = "|";

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        logger.info("Custom Reset Credential Email");
        UserModel user = context.getUser();
        AuthenticationSessionModel authenticationSession = context.getAuthenticationSession();
        String username = authenticationSession.getAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME);

        String forgotPasswordEmailMessage = String.format("%s%s%s",FORGOT_PASSWORD_EMAIL_SENT,FORGOT_PASSWORD_EMAIL_SEPARATOR,username);

        // we don't want people guessing usernames, so if there was a problem obtaining the user, the user will be null.
        // just reset login for with a success message
        if (user == null) {
            context.forkWithSuccessMessage(new FormMessage(forgotPasswordEmailMessage));
            return;
        }

        String actionTokenUserId = authenticationSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
        if (actionTokenUserId != null && Objects.equals(user.getId(), actionTokenUserId)) {
            logger.debugf("Forget-password triggered when reauthenticating user after authentication via action token. Skipping " + PROVIDER_ID + " screen and using user '%s' ", user.getUsername());
            context.success();
            return;
        }


        EventBuilder event = context.getEvent();
        // we don't want people guessing usernames, so if there is a problem, just continuously challenge
        if (user.getEmail() == null || user.getEmail().trim().length() == 0) {
            event.user(user)
                .detail(Details.USERNAME, username)
                .error(Errors.INVALID_EMAIL);

            context.forkWithSuccessMessage(new FormMessage(forgotPasswordEmailMessage));
            return;
        }

        int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan(ResetCredentialsActionToken.TOKEN_TYPE);
        int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;

        // We send the secret in the email in a link as a query param.
        String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authenticationSession).getEncodedId();
        ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), user.getEmail(), absoluteExpirationInSecs, authSessionEncodedId, authenticationSession.getClient().getClientId());
        String link = UriBuilder
            .fromUri(context.getActionTokenUrl(token.serialize(context.getSession(), context.getRealm(), context.getUriInfo())))
            .build()
            .toString();
        long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);
        try {
            context.getSession()
                .getProvider(EmailTemplateProvider.class)
                .setRealm(context.getRealm())
                .setUser(user)
                .setAuthenticationSession(authenticationSession)
                .sendPasswordReset(link, expirationInMinutes);

            event.clone().event(EventType.SEND_RESET_PASSWORD)
                .user(user)
                .detail(Details.USERNAME, username)
                .detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, authenticationSession.getParentSession().getId()).success();
            context.forkWithSuccessMessage(new FormMessage(forgotPasswordEmailMessage));
        } catch (EmailException e) {
            event.clone().event(EventType.SEND_RESET_PASSWORD)
                .detail(Details.USERNAME, username)
                .user(user)
                .error(Errors.EMAIL_SEND_FAILED);
            ServicesLogger.LOGGER.failedToSendPwdResetEmail(e);
            Response challenge = context.form()
                .setError(Messages.EMAIL_SENT_ERROR)
                .createErrorPage(Response.Status.INTERNAL_SERVER_ERROR);
            context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
        }
    }

}
public class CustomResetCredentialEmailFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {

    public static final String PROVIDER_ID = "reset-credential-email";
    private static final CustomResetCredentialEmail SINGLETON = new CustomResetCredentialEmail();

    @Override
    public String getDisplayType() {
        return "Send Reset Email";
    }

    @Override
    public String getReferenceCategory() {
        return null;
    }

    @Override
    public boolean isConfigurable() {
        return false;
    }

    public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
        AuthenticationExecutionModel.Requirement.REQUIRED
    };

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return REQUIREMENT_CHOICES;
    }

    @Override
    public boolean isUserSetupAllowed() {
        return false;
    }

    @Override
    public String getHelpText() {
        return "Send email to user and wait for response.";
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return null;
    }

    @Override
    public void close() {

    }

    @Override
    public Authenticator create(KeycloakSession session) {
        return SINGLETON;
    }

    @Override
    public void init(Config.Scope config) {

    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {

    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }
}

Inside the META-INF/services directory, the file org.keycloak.authentication.AuthenticatorFactory was added with the full name of the factory class inside.

At keycloak startup the new authenticator is recognised and loaded,

2023-03-31 07:41:10,994 WARN [org.keycloak.services] (build-10) KC-SERVICES0047: reset-credential-email (com.keycloakspi.authenticator.resetpassword.CustomResetCredentialEmailFactory) is implementing the internal SPI authenticator. This SPI is internal and may change without notice

but after upgrading to version 21 the authenticate method invoked still seems to be that of the default class and not the extended one (it worked with version 20 of keycloak).

Can anyone tell me whether anything has changed in the configuration/extension of authenticators in version 21?