Custom SPI - Link for user to set their initial password

I’ve implemented a custom SPI extending the EventListenerProvider, and I am sending an email to a newly created user. I am generating a token, and building a url to be put into the email. That part is all working.

What I would like to change is where the link leads. Currently, this is the link - http://KEYCLOAK_BASE_URL/realms/MY_REALM/login-actions/action-token?key=TOKEN_GOES_HERE&client_id=my-client&tab_id=

Which leads to this page

When I click “Click here to proceed” the URL changes to -
http://KEYCLOAK_BASE_URL/realms/MY_REALM/login-actions/required-action?execution=VERIFY_EMAIL&client_id=my-client&tab_id=y3L5VgtTr-Q

which is the page that says “You need to change your password to activate your account.” and allows the user to set their new password. (Tried embedding an image, wouldn’t let me do more than one)

How can I generate the link in the URL to go directly to the second screen, bypassing the first? Here is my code:

List<String> actions = Arrays.asList();  //whether or not I put RESET_PASSWORD here or not, doesn't matter

ExecuteActionsActionToken token = new ExecuteActionsActionToken(userId,  absoluteExpirationInSecs,
  actions, redirectUri, clientId);

UriInfo uriInfo = session.getContext().getUri();

String tokenStr = token.serialize(session, realm, uriInfo);

UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), tokenStr, 
  clientId,"");  //I wasn't sure how to get the tab id? Would that solve this?
String link = builder.build(realm.getName()).toString();

Just wanted to follow up on anyone looking to solve this same problem. Credit to @claudiunicolaa on his post here - Bypass "Perform the following action(s)" modal - #3 by gwin003

I’ll add my code, but most of it was lifted directly from the extended classes.

What you want to do is create these 3 files:

CustomExecuteActionsActionToken

public class CustomExecuteActionsActionToken extends DefaultActionToken {

  private static final Logger LOG = Logger.getLogger(CustomExecuteActionsActionToken.class);

  public static final String TOKEN_TYPE = "custom-token";
  private static final String JSON_FIELD_REQUIRED_ACTIONS = "rqac";
  private static final String JSON_FIELD_REDIRECT_URI = "reduri";

  public CustomExecuteActionsActionToken(String userId, int absoluteExpirationInSecs, List<String> requiredActions,
      String redirectUri, String clientId) {
    super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null);
    setRequiredActions(requiredActions == null ? new LinkedList<>() : new LinkedList<>(requiredActions));
    setRedirectUri(redirectUri);
    this.issuedFor = clientId;
  }

  private CustomExecuteActionsActionToken() {
    // Required to deserialize from JWT
    super();
  }

  @JsonProperty(value = JSON_FIELD_REQUIRED_ACTIONS)
  public List<String> getRequiredActions() {
    return (List<String>) getOtherClaims().get(JSON_FIELD_REQUIRED_ACTIONS);
  }

  @JsonProperty(value = JSON_FIELD_REQUIRED_ACTIONS)
  public final void setRequiredActions(List<String> requiredActions) {
    if (requiredActions == null) {
      getOtherClaims().remove(JSON_FIELD_REQUIRED_ACTIONS);
    } else {
      setOtherClaims(JSON_FIELD_REQUIRED_ACTIONS, requiredActions);
    }
  }

  @JsonProperty(value = JSON_FIELD_REDIRECT_URI)
  public String getRedirectUri() {
    return (String) getOtherClaims().get(JSON_FIELD_REDIRECT_URI);
  }

  @JsonProperty(value = JSON_FIELD_REDIRECT_URI)
  public final void setRedirectUri(String redirectUri) {
    if (redirectUri == null) {
      getOtherClaims().remove(JSON_FIELD_REDIRECT_URI);
    } else {
      setOtherClaims(JSON_FIELD_REDIRECT_URI, redirectUri);
    }
  }

}

CustomExecuteActionsActionTokenHandler


public class CustomExecuteActionsActionTokenHandler extends AbstractActionTokenHandler<CustomExecuteActionsActionToken> {

  public CustomExecuteActionsActionTokenHandler() {
    super(CustomExecuteActionsActionToken.TOKEN_TYPE,
        CustomExecuteActionsActionToken.class,
        Messages.INVALID_CODE,
        EventType.EXECUTE_ACTIONS,
        Errors.NOT_ALLOWED);
  }

  private static final Logger LOG = Logger.getLogger(CustomExecuteActionsActionTokenHandler.class);

  @Override
  public Response handleToken(CustomExecuteActionsActionToken token,
      ActionTokenContext<CustomExecuteActionsActionToken> tokenContext) {
    AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
    final UriInfo uriInfo = tokenContext.getUriInfo();
    final RealmModel realm = tokenContext.getRealm();
    final KeycloakSession session = tokenContext.getSession();

    LOG.debugf("CustomExecuteActionsActionTokenHandler: handleToken");

    if (tokenContext.isAuthenticationSessionFresh()) {
      // Update the authentication session in the token
      String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId();
      token.setCompoundAuthenticationSessionId(authSessionEncodedId);
      // THE BELOW CODE IS THE IMPORTANT PIECE TO COMMENT OUT
      /*
       * UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(),
       * token.serialize(session, realm, uriInfo),
       * authSession.getClient().getClientId(), authSession.getTabId());
       * String confirmUri = builder.build(realm.getName()).toString();
       * 
       * return session.getProvider(LoginFormsProvider.class)
       * .setAuthenticationSession(authSession)
       * .setSuccess(Messages.CONFIRM_EXECUTION_OF_ACTIONS)
       * .setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri)
       * .setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS,
       * token.getRequiredActions())
       * .createInfoPage();
       */
    }

    String redirectUri = RedirectUtils.verifyRedirectUri(tokenContext.getSession(), token.getRedirectUri(),
        authSession.getClient());

    LOG.debugf("CustomExecuteActionsActionTokenHandler.redirectUri: %s", redirectUri);

    if (redirectUri != null) {
      authSession.setAuthNote(AuthenticationManager.SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS, "true");

      authSession.setRedirectUri(redirectUri);
      authSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri);
    }

    token.getRequiredActions().stream().forEach(authSession::addRequiredAction);

    UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser();
    // verify user email as we know it is valid as this entry point would never have
    // gotten here.
    user.setEmailVerified(true);

    String nextAction = AuthenticationManager.nextRequiredAction(tokenContext.getSession(), authSession,
        tokenContext.getRequest(), tokenContext.getEvent());
    return AuthenticationManager.redirectToRequiredActions(tokenContext.getSession(), tokenContext.getRealm(),
        authSession, tokenContext.getUriInfo(), nextAction);

  }

  // everything below this is a copy pasta of ExecuteActionsActionTokenHandler
  @Override
  public Predicate<? super CustomExecuteActionsActionToken>[] getVerifiers(
      ActionTokenContext<CustomExecuteActionsActionToken> tokenContext) {
    return TokenUtils.predicates(
        TokenUtils.checkThat(
            // either redirect URI is not specified or must be valid for the client
            t -> t.getRedirectUri() == null
                || RedirectUtils.verifyRedirectUri(tokenContext.getSession(), t.getRedirectUri(),
                    tokenContext.getAuthenticationSession().getClient()) != null,
            Errors.INVALID_REDIRECT_URI,
            Messages.INVALID_REDIRECT_URI),

        verifyEmail(tokenContext));
  }

  @Override
  public boolean canUseTokenRepeatedly(CustomExecuteActionsActionToken token,
      ActionTokenContext<CustomExecuteActionsActionToken> tokenContext) {
    RealmModel realm = tokenContext.getRealm();
    KeycloakSessionFactory sessionFactory = tokenContext.getSession().getKeycloakSessionFactory();

    return token.getRequiredActions().stream()
        .map(actionName -> realm.getRequiredActionProviderByAlias(actionName)) // get realm-specific model from action
                                                                               // name and filter out irrelevant
        .filter(Objects::nonNull)
        .filter(RequiredActionProviderModel::isEnabled)

        .map(RequiredActionProviderModel::getProviderId) // get provider ID from model

        .map(providerId -> (RequiredActionFactory) sessionFactory.getProviderFactory(RequiredActionProvider.class,
            providerId))
        .filter(Objects::nonNull)

        .noneMatch(RequiredActionFactory::isOneTimeAction);
  }
}

CustomExecuteActionsActionTokenHandlerFactory

package customStuff;

import org.keycloak.Config.Scope;
import org.keycloak.authentication.actiontoken.ActionTokenHandler;
import org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

public class CustomExecuteActionsActionTokenHandlerFactory implements ActionTokenHandlerFactory<CustomExecuteActionsActionToken> {


  @Override
  public String getId() {
    return CustomExecuteActionsActionToken.TOKEN_TYPE;
  }

  @Override
  public ActionTokenHandler<CustomExecuteActionsActionToken> create(KeycloakSession session) {
    return new CustomExecuteActionsActionTokenHandler();
  }

  @Override
  public void init(Scope config) {
    
  }

  @Override
  public void postInit(KeycloakSessionFactory factory) {
  }

  @Override
  public void close() {
  }
  
}

Remember to register your CustomExecuteActionsActionTokenHandlerFactory as you would other SPIs. (org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory is the file name)

After adding these three files and registering the handler factory, you can use your CustomExecuteActionsActionToken in your other custom SPIs.

1 Like