SPI to append data to username on authentication

Hi team,

I’m trying (for 3 days already) to find a solution to my scenario:

  • My users are identified using a number (CPF, it’s like a social security number in brazil). So the username is something like 12345678901
  • They also have the email address, everyone uses the same domain @iftm.edu.br (we are a public school)
  • The realm is configured to allow login with username or email

The users need to be able to login using only the first part of the email, without needing to fill the complete address on the input. So a user with CPF = 12345678901 and email carlos@iftm.edu.br must be able to login using just ‘carlos’ as username.

I found that I could implement an SPI to build a custom authenticator, and append the ‘@iftm.edu.br’ part to the input value. So i built the jar, deployed on my Keycloak and set-up the flow, replacing the form with my SPI.

It works when the users try to login using CPF or email (I’ve added log messages to check if this cases are being ignored).

When the user tries to login using the first part of the email address, the SPI do append the domain to it, but it seems that it keeps using the original value, and the login fails.

I don’t know what else to do, so I’m asking for help.

Thank you in advance =)

the code:

package org.keycloak.auth;

import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm;
import jakarta.ws.rs.core.MultivaluedMap;

public class UsernameAutoCompleteAuthenticator extends UsernamePasswordForm implements Authenticator {

    private static final Logger logger = Logger.getLogger(UsernameAutoCompleteAuthenticator.class);
    private static final String EMAIL_DOMAIN = "@iftm.edu.br";

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        super.authenticate(context);    
    }    

    @Override
    public void action(AuthenticationFlowContext context) {
        logger.info("*** AUTOCOMPLETE *** itworks");        

        // Retrieve the username from the form data
        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        String username = formData.getFirst("username");

        if (username != null) {
            if (username.matches("\\d{11}")) {
                // Username is a sequence of 11 numbers, no modification needed
                logger.warnf(" *** AUTOCOMPLETE *** Username is a CPF, no modification needed: %s", username);
            } else if (!username.contains("@")) {
                // Append the email domain if not already present
                username += EMAIL_DOMAIN;
                logger.infof(" *** AUTOCOMPLETE *** Updated username with domain: %s", username);
                
                // None of this seems to work
                formData.putSingle("username", username);
                context.getAuthenticationSession().setAuthNote("username", username);
                context.getSession().setAttribute("username", username);
            } else {
                logger.warnf(" *** AUTOCOMPLETE *** Username already contains '@', no modification needed: %s", username);
            }
        }

        // Call the default UsernamePasswordForm behavior
        super.action(context);

        // Ensure the flow status is set
        if (context.getStatus() == null) {
            logger.warn("Flow status is null. Setting failure status.");
            context.failure(AuthenticationFlowError.INTERNAL_ERROR);
        }

        logger.debug("*** AUTOCOMPLETE *** Exiting action method.");
    }

    @Override public boolean requiresUser() { return false; }
    @Override public boolean configuredFor(org.keycloak.models.KeycloakSession session, org.keycloak.models.RealmModel realm, org.keycloak.models.UserModel user) { return true; }
    @Override public void setRequiredActions(org.keycloak.models.KeycloakSession session, org.keycloak.models.RealmModel realm, org.keycloak.models.UserModel user) {}
    @Override public void close() {}
}

the flow:

the console output:

I’ve got the solution to this from Martin Bartos (Thank you again) at Keycloak chanel on Slack, so I reproduce it here for people with a similar use-case:

The issue in this case is that the current impl of context.getHttpRequest().getDecodedFormParameters() always returns a new instance of MultivaluedMap on every call with form params obtained from RESTEasy context. It means that even though you update these items in the multivaluedMap, the map is recreated in the UsernamePasswordForm#action() as there’s another call of context.getHttpRequest().getDecodedFormParameters().

It means you would not be able to call super.action(context), but probably mimic the behavior of the UsernamePasswordForm#action with your multivaluedMap. I know it’s not ideal from a maintenance PoV, but probably the only solution to your use case.

What you need to do instead of calling the super.action() explicitly add the logic with your multivaluedMap:

MultivaluedMap<String, String> formData = <your-form-data>;
if (formData.containsKey("cancel")) {
    context.cancelLogin();
    return;
}
if (!validateForm(context, formData)) {
    return;
}
context.success();

I would also recommend to use this instead of putSingle() - to be completely sure:

formData.put(AuthenticationManager.FORM_USERNAME, List.of(username));