Debug School

rakesh kumar
rakesh kumar

Posted on

Displaying Server-Side Validation Errors in Keycloak FTL Forms

Render serverside msg in ftl forms
Render Multiple field errors in ftl forms
Render laravel dynamic msg in FTL forms

Render serverside msg in ftl forms

STEP 1️⃣ Add message display in FTL (you already have this)

You already wrote this (correct):

<#if message?has_content>
  <div class="alert alert-error">
    ${message.summary}
  </div>
</#if>
Enter fullscreen mode Exit fullscreen mode

This displays server-side errors automatically.

STEP 2️⃣ Do validation in Java action() method
Example: PhoneInputAuthenticator.action()

@Override
public void action(AuthenticationFlowContext context) {

    MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();

    String country = formData.getFirst("country");
    String phone   = formData.getFirst("phone");

    // ❌ SERVER-SIDE VALIDATION
    if (country == null || country.isBlank()) {
        context.failureChallenge(
            AuthenticationFlowError.INVALID_CREDENTIALS,
            context.form()
                   .setError("Country is required")
                   .createForm("phone-login.ftl")
        );
        return;
    }

    if (phone == null || !phone.matches("\\d{10}")) {
        context.failureChallenge(
            AuthenticationFlowError.INVALID_CREDENTIALS,
            context.form()
                   .setError("Phone number must be 10 digits")
                   .createForm("phone-login.ftl")
        );
        return;
    }

    // ✅ VALID → continue
    context.success();
}
Enter fullscreen mode Exit fullscreen mode

STEP 3️⃣ What actually happens internally

When you call:

context.form()
       .setError("Phone number must be 10 digits")
       .createForm("phone-login.ftl");
Enter fullscreen mode Exit fullscreen mode

Keycloak:

Stores the error message

Re-renders the same FTL

Injects message.summary

Your <#if message?has_content> block displays it

STEP 4️⃣ Important: DON’T use context.success() on error

❌ Wrong:

context.success(); // even on error
Enter fullscreen mode Exit fullscreen mode

✅ Correct:

context.failureChallenge(...)
Enter fullscreen mode Exit fullscreen mode

Render Multiple field errors in ftl forms

Multiple field errors (optional, advanced)

You can show multiple errors:

context.challenge(
    context.form()
           .setErrors(List.of(
               new FormMessage("phone", "Invalid phone number"),
               new FormMessage("country", "Country required")
           ))
           .createForm("phone-login.ftl")
);
Enter fullscreen mode Exit fullscreen mode
  public void action(AuthenticationFlowContext context) {

        MultivaluedMap<String, String> formData =
                context.getHttpRequest().getDecodedFormParameters();

        String country = trimToNull(formData.getFirst("country"));
        String phone   = trimToNull(formData.getFirst("phone"));

        // Collect multiple field errors
        List<FormMessage> errors = new ArrayList<>();

        // ----- VALIDATIONS -----
        if (country == null) {
            errors.add(new FormMessage("country", "Country is required"));
        }

        if (phone == null) {
            errors.add(new FormMessage("phone", "Phone number is required"));
        } else {
            // Example: only digits and length 10 (adjust as per your rules)
            if (!phone.matches("\\d+")) {
                errors.add(new FormMessage("phone", "Phone must contain only digits"));
            } else if (phone.length() != 10) {
                errors.add(new FormMessage("phone", "Phone number must be 10 digits"));
            }
        }

        // If there are any errors -> re-render same FTL with errors
        if (!errors.isEmpty()) {
            context.failureChallenge(
                AuthenticationFlowError.INVALID_CREDENTIALS,
                context.form()
                       // preserve typed values
                       .setAttribute("countryValue", country)
                       .setAttribute("phoneValue", phone)
                       // attach multiple errors
                       .setErrors(errors)
                       .createForm("phone-login.ftl")
            );
            return;
        }

        // ✅ If valid -> continue flow
        context.success();
    }
Enter fullscreen mode Exit fullscreen mode

And in FTL:


<#list messagesPerField?keys as field>
  <div class="error">${messagesPerField[field]?first}</div>
</#list>
Enter fullscreen mode Exit fullscreen mode
   <form id="kc-phone-form" action="${url.loginAction}" method="post">

      <!-- COUNTRY -->
      <div class="form-group">
        <label>Country Code</label>
        <select name="country" class="form-control" required>
          <option value="">Select Country</option>

          <option value="+91" <#if (countryValue!"") == "+91">selected</#if>>India (+91)</option>
          <option value="+1"  <#if (countryValue!"") == "+1">selected</#if>>USA (+1)</option>
          <option value="+81" <#if (countryValue!"") == "+81">selected</#if>>Japan (+81)</option>
        </select>

        <!-- Field error under country -->
        <#if messagesPerField?? && messagesPerField.exists('country')>
          <div class="error" style="color:#d32f2f; margin-top:6px;">
            ${messagesPerField.get('country')?first}
          </div>
        </#if>
      </div>

      <!-- PHONE -->
      <div class="form-group">
        <label>Phone</label>
        <input name="phone"
               class="form-control"
               placeholder="Enter phone number"
               value="${phoneValue!""}"
               required />

        <!-- Field error under phone -->
        <#if messagesPerField?? && messagesPerField.exists('phone')>
          <div class="error" style="color:#d32f2f; margin-top:6px;">
            ${messagesPerField.get('phone')?first}
          </div>
        </#if>
      </div>

      <button class="btn btn-primary" type="submit">Send OTP</button>
    </form>
Enter fullscreen mode Exit fullscreen mode

ONE-SENTENCE RULE (remember this)

Server-side validation = context.failureChallenge() + form().setError() + re-render FTL
Enter fullscreen mode Exit fullscreen mode

ONE-LINE GOLDEN RULE

If anything fails → failureChallenge() + re-render FTL
If everything succeeds → context.success()
Enter fullscreen mode Exit fullscreen mode

Render laravel dynamic msg in FTL forms

Change Laravel to always return message

You already do this:

{ "success": false, "message": "Failed to send WhatsApp message." }
Enter fullscreen mode Exit fullscreen mode

Good.

2) In Java, parse JSON and return message
Create a small response class

static class LaravelOtpResponse {
    final boolean success;
    final String message;

    LaravelOtpResponse(boolean success, String message) {
        this.success = success;
        this.message = message;
    }
}
Enter fullscreen mode Exit fullscreen mode

Update your API call method to return this object

(Using Keycloak’s built-in JSON utility:

org.keycloak.util.JsonSerialization)

import java.util.Map;
import org.keycloak.util.JsonSerialization;

private LaravelOtpResponse sendOtpViaLaravel(String phone) throws Exception {

    URL url = new URL("https://api.motoshare.in/auth/send-whatsapp-otp");
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();

    conn.setRequestMethod("POST");
    conn.setRequestProperty("Content-Type", "application/json");
    conn.setRequestProperty("Authorization", "Bearer INTERNAL_SECRET");
    conn.setDoOutput(true);

    String json = "{\"phone\":\"" + phone.replace("\"", "") + "\"}";

    try (OutputStream os = conn.getOutputStream()) {
        os.write(json.getBytes(StandardCharsets.UTF_8));
    }

    int status = conn.getResponseCode();
    InputStream is = (status >= 200 && status < 300)
            ? conn.getInputStream()
            : conn.getErrorStream();

    String responseBody;
    try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
        responseBody = br.lines().collect(Collectors.joining());
    }

    // Parse JSON like: {"success":true,"message":"..."}
    Map<String, Object> map = JsonSerialization.readValue(responseBody, Map.class);

    boolean success = Boolean.TRUE.equals(map.get("success"));
    String message = (map.get("message") != null) ? String.valueOf(map.get("message")) : null;

    // fallback message if Laravel didn't send one
    if (message == null || message.isBlank()) {
        message = success ? "OTP sent." : "Failed to send OTP.";
    }

    return new LaravelOtpResponse(success, message);
}
Enter fullscreen mode Exit fullscreen mode

3) Use Laravel message in action() and render in FTL

@Override
public void action(AuthenticationFlowContext context) {

    MultivaluedMap<String, String> formData =
            context.getHttpRequest().getDecodedFormParameters();

    String phone = formData.getFirst("phone");

    if (phone == null || phone.isBlank()) {
        context.failureChallenge(
            AuthenticationFlowError.INVALID_CREDENTIALS,
            context.form()
                   .setError("Phone number is required")
                   .createForm("phone-login.ftl")
        );
        return;
    }

    try {
        LaravelOtpResponse laravelResp = sendOtpViaLaravel(phone);

        if (!laravelResp.success) {
            // ✅ SHOW DYNAMIC MESSAGE FROM LARAVEL IN FTL
            context.failureChallenge(
                AuthenticationFlowError.INVALID_CREDENTIALS,
                context.form()
                       .setError(laravelResp.message)   // <-- dynamic
                       .createForm("phone-login.ftl")
            );
            return;
        }

        // Optional: show success message too (only if you want)
        // context.form().setSuccess(laravelResp.message); // if you want banner

    } catch (Exception e) {
        context.failureChallenge(
            AuthenticationFlowError.INTERNAL_ERROR,
            context.form()
                   .setError("OTP service is temporarily unavailable.")
                   .createForm("phone-login.ftl")
        );
        return;
    }

    context.success();
}
Enter fullscreen mode Exit fullscreen mode

Now your existing FTL block shows it:

<#if message?has_content>
  <div class="alert alert-error">${message.summary}</div>
</#if>
Enter fullscreen mode Exit fullscreen mode

4) If you want “success message” also on same page

Instead of moving forward immediately, you can re-render with a green banner.

In Java:


context.challenge(
    context.form()
           .setSuccess(laravelResp.message)
           .createForm("phone-login.ftl")
);
return;
Enter fullscreen mode Exit fullscreen mode

In FTL:

<#if message?has_content>
  <div class="alert ${message.type}">
    ${message.summary}
  </div>
</#if>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)