Debug School

rakesh kumar
rakesh kumar

Posted on

How to fetch and Store User Data in Both Keycloak and Laravel Using a Custom Authenticator

Call Laravel API from Keycloak (Java) to send otp
How to fetch country data in ftl forms
How to Store data in Keycloak database
How to Store data in Laravel database

How to fetch country data in ftl forms

Correct architecture (important)

FTL must not call Laravel directly.
FTL only renders data given by Java.
Enter fullscreen mode Exit fullscreen mode

So flow is:


Keycloak authenticate()
   ↓
Java calls Laravel API
   ↓
Java puts country list into form attributes
   ↓
FTL renders dropdown
Enter fullscreen mode Exit fullscreen mode

STEP 1️⃣ Laravel API (example)

Laravel returns countries like this:

{
  "success": true,
  "data": [
    { "code": "+91", "name": "India" },
    { "code": "+1",  "name": "United States" },
    { "code": "+81", "name": "Japan" }
  ]
Enter fullscreen mode Exit fullscreen mode

}

Endpoint:

GET https://api.motoshare.in/meta/countries
Enter fullscreen mode Exit fullscreen mode

STEP 2️⃣ Call Laravel API in Keycloak (Java)
Helper method in your Authenticator

import org.keycloak.util.JsonSerialization;

private List<Map<String, String>> fetchCountriesFromLaravel() throws Exception {

    URL url = new URL("https://api.motoshare.in/meta/countries");
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();

    conn.setRequestMethod("GET");
    conn.setRequestProperty("Accept", "application/json");
    conn.setRequestProperty("Authorization", "Bearer INTERNAL_SECRET");

    int status = conn.getResponseCode();
    if (status != 200) {
        throw new RuntimeException("Failed to fetch countries");
    }

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

    Map<String, Object> json = JsonSerialization.readValue(body, Map.class);

    // Extract "data" array
    return (List<Map<String, String>>) json.get("data");
}
Enter fullscreen mode Exit fullscreen mode

This returns a Java list like:

[
  {code=+91, name=India},
  {code=+1, name=United States}
]
Enter fullscreen mode Exit fullscreen mode

STEP 3️⃣ Use it in authenticate() method

Modify your authenticate() like this:

@Override
public void authenticate(AuthenticationFlowContext context) {

    List<Map<String, String>> countries;

    try {
        countries = fetchCountriesFromLaravel();
    } catch (Exception e) {
        // fallback if API fails
        countries = List.of(
            Map.of("code", "+91", "name", "India")
        );
    }

    Response challenge = context.form()
            .setAttribute("realmName", context.getRealm().getName())
            .setAttribute("countries", countries) // 👈 PASS TO FTL
            .createForm("phone-login.ftl");

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

STEP 4️⃣ Render dynamic dropdown in FTL
phone-login.ftl

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

    <#list countries as c>
      <option value="${c.code}">
        ${c.name} (${c.code})
      </option>
    </#list>

  </select>
</div>
Enter fullscreen mode Exit fullscreen mode

That’s it.
No JS. No AJAX. Fully server-side.

STEP 5️⃣ Preserve selected country after validation error (recommended)
Java (before re-rendering on error)

context.form()
       .setAttribute("countries", countries)
       .setAttribute("selectedCountry", country)
Enter fullscreen mode Exit fullscreen mode

FTL

<option value="${c.code}"
  <#if selectedCountry?? && selectedCountry == c.code>selected</#if>>
  ${c.name} (${c.code})
</option>
Enter fullscreen mode Exit fullscreen mode

Call Laravel API from Keycloak (Java) to send otp

Java helper method (clean & safe)

private boolean 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 + "\"}";

    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 response = new BufferedReader(new InputStreamReader(is))
            .lines().collect(Collectors.joining());

    // simple JSON check (you can use Jackson later)
    return response.contains("\"success\":true");
}
Enter fullscreen mode Exit fullscreen mode

3️⃣ Use it inside action() method (THIS is the key part)

PhoneInputAuthenticator.action()
@Override
public void action(AuthenticationFlowContext context) {

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

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

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

    try {
        // 2️⃣ Call Laravel WhatsApp OTP API
        boolean sent = sendOtpViaLaravel(phone);

        if (!sent) {
            context.failureChallenge(
                AuthenticationFlowError.INTERNAL_ERROR,
                context.form()
                       .setError("Unable to send OTP via WhatsApp. Try again.")
                       .createForm("phone-login.ftl")
            );
            return;
        }

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

    // 3️⃣ Success → go to next execution
    context.success();
}
Enter fullscreen mode Exit fullscreen mode

4️⃣ How error shows in FTL (automatic)

Your FTL already has:

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

How to Store data in Keycloak database

UserModel user = context.getUser();

if (user != null) {
    user.setSingleAttribute("phone", phone);
}
Enter fullscreen mode Exit fullscreen mode

If user does not exist yet (phone login case):

RealmModel realm = context.getRealm();

UserModel user = context.getSession().users().addUser(realm, phone);
user.setEnabled(true);
user.setUsername(phone);
user.setSingleAttribute("phone", phone);

context.setUser(user);
Enter fullscreen mode Exit fullscreen mode

Full code to Store data in Keycloak database

Helper method: “create or update user + store phone attribute”

import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.authentication.AuthenticationFlowContext;

public class KeycloakUserStore {

    /**
     * Store phone in Keycloak DB as a user attribute.
     * If the user doesn't exist, create it.
     */
    public static UserModel createOrUpdateUserWithPhone(AuthenticationFlowContext context, String phone) {

        RealmModel realm = context.getRealm();

        // Decide username strategy (here: phone as username)
        String username = phone;

        // 1) Try to find existing user by username
        UserModel user = context.getSession().users().getUserByUsername(realm, username);

        // 2) If not found -> create
        if (user == null) {
            user = context.getSession().users().addUser(realm, username);
            user.setEnabled(true);
            user.setUsername(username);
        }

        // 3) Store phone attribute in Keycloak DB
        user.setSingleAttribute("phone", phone);

        // 4) Set user into login context (VERY IMPORTANT)
        context.setUser(user);

        return user;
    }
}
Enter fullscreen mode Exit fullscreen mode

Full example: PhoneInputAuthenticator.java (stores phone in Keycloak DB)
package in.motoshare.auth.phoneotp;

import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.UserModel;

public class PhoneInputAuthenticator implements Authenticator {

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // Show your FTL page
        Response challenge = context.form().createForm("phone-login.ftl");
        context.challenge(challenge);
    }

    @Override
    public void action(AuthenticationFlowContext context) {

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

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

        // ✅ Server-side validation
        if (phone == null || phone.trim().isEmpty()) {
            context.failureChallenge(
                    AuthenticationFlowError.INVALID_CREDENTIALS,
                    context.form()
                           .setError("Phone number is required")
                           .createForm("phone-login.ftl")
            );
            return;
        }

        phone = phone.trim();

        try {
            // ✅ Store in Keycloak DB (create user if needed)
            UserModel user = KeycloakUserStore.createOrUpdateUserWithPhone(context, phone);

            // OPTIONAL: store other attributes
            // user.setSingleAttribute("country", formData.getFirst("country"));

        } catch (Exception e) {
            context.failureChallenge(
                    AuthenticationFlowError.INTERNAL_ERROR,
                    context.form()
                           .setError("Unable to save phone in Keycloak DB")
                           .createForm("phone-login.ftl")
            );
            return;
        }

        // ✅ Continue flow
        context.success();
    }

    @Override public boolean requiresUser() { return false; }
    @Override public boolean configuredFor(org.keycloak.models.KeycloakSession s,
                                           org.keycloak.models.RealmModel r,
                                           org.keycloak.models.UserModel u) { return true; }
    @Override public void setRequiredActions(org.keycloak.models.KeycloakSession s,
                                             org.keycloak.models.RealmModel r,
                                             org.keycloak.models.UserModel u) {}
    @Override public void close() {}
}
Enter fullscreen mode Exit fullscreen mode

✅ 3) If you want to use context.getUser() first (your exact wording)

Sometimes the flow already set a user (rare in phone-first login). If you want that logic exactly:

RealmModel realm = context.getRealm();
UserModel user = context.getUser();

if (user != null) {
    user.setSingleAttribute("phone", phone);
    context.setUser(user);
} else {
    UserModel existing = context.getSession().users().getUserByUsername(realm, phone);

    if (existing == null) {
        existing = context.getSession().users().addUser(realm, phone);
        existing.setEnabled(true);
        existing.setUsername(phone);
    }

    existing.setSingleAttribute("phone", phone);
    context.setUser(existing);
}
Enter fullscreen mode Exit fullscreen mode

How to Store data in Laravel database

Example Laravel API (server side)
POST https://api.motoshare.in/auth/store-phone
Content-Type: application/json
Authorization: Bearer INTERNAL_SECRET

Payload:


{
  "phone": "+917000000000",
  "country": "+91"
}
Enter fullscreen mode Exit fullscreen mode

Call Laravel API from Keycloak (Java)

private void callLaravel(String phone, String country) throws Exception {

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

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

    String json = "{\"phone\":\"" + phone + "\",\"country\":\"" + country + "\"}";

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

    int code = conn.getResponseCode();
    if (code != 200) {
        throw new RuntimeException("Laravel DB save failed");
    }
}
Enter fullscreen mode Exit fullscreen mode

PART 3️⃣ Combine everything inside action()
Full correct pattern

@Override
public void action(AuthenticationFlowContext context) {

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

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

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

    try {
        // 2️⃣ STORE IN KEYCLOAK DB
        RealmModel realm = context.getRealm();
        UserModel user = context.getSession().users().getUserByUsername(realm, phone);

        if (user == null) {
            user = context.getSession().users().addUser(realm, phone);
            user.setEnabled(true);
            user.setSingleAttribute("phone", phone);
        }

        context.setUser(user);

        // 3️⃣ STORE IN LARAVEL DB
        callLaravel(phone, country);

    } catch (Exception e) {

        // ❌ ANY ERROR → SHOW IN FTL
        context.failureChallenge(
            AuthenticationFlowError.INTERNAL_ERROR,
            context.form()
                   .setError("Unable to save data. Please try again.")
                   .createForm("phone-login.ftl")
        );
        return;
    }

    // ✅ ALL OK → MOVE TO NEXT EXECUTION
    context.success();
}
Enter fullscreen mode Exit fullscreen mode
package in.motoshare.auth.phoneotp;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;

import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;

import org.keycloak.models.AuthenticationSessionModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import org.keycloak.util.JsonSerialization;

public class PhoneInputAuthenticator implements Authenticator {

    // ✅ Change to your real API base
    private static final String LARAVEL_BASE_URL = "https://api.motoshare.in";
    // ✅ Use a real secret / token
    private static final String INTERNAL_SECRET = "INTERNAL_SECRET";

    // Pages
    private static final String FTL_PHONE_LOGIN = "phone-login.ftl";

    // Session notes keys (optional)
    private static final String NOTE_APP_BASE = "APP_BASE_URL";

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        String realmName = context.getRealm().getName();

        // 1) Get app base url (OPTIONAL) - derived from redirect_uri if you want
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        String redirectUri = authSession.getRedirectUri(); // e.g. http://127.0.0.1:8000/auth/keycloak/callback
        String appBaseUrl = deriveBaseUrl(redirectUri);
        if (appBaseUrl != null) {
            authSession.setAuthNote(NOTE_APP_BASE, appBaseUrl);
        }

        // 2) Fetch countries from Laravel for dropdown
        List<Map<String, Object>> countries;
        try {
            countries = fetchCountriesFromLaravel();
        } catch (Exception e) {
            // fallback so login page still loads
            countries = List.of(
                Map.of("code", "+91", "name", "India"),
                Map.of("code", "+1", "United States"),
                Map.of("code", "+81", "Japan")
            );
        }

        Response challenge = context.form()
            .setAttribute("realmName", realmName)      // from Keycloak
            .setAttribute("appBaseUrl", appBaseUrl)    // optional
            .setAttribute("countries", countries)      // from Laravel
            .createForm(FTL_PHONE_LOGIN);

        context.challenge(challenge);
    }

    @Override
    public void action(AuthenticationFlowContext context) {

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

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

        // Always refetch countries for re-render on error (or cache them)
        List<Map<String, Object>> countries;
        try {
            countries = fetchCountriesFromLaravel();
        } catch (Exception e) {
            countries = Collections.emptyList();
        }

        // ✅ Server-side validation
        if (country == null) {
            renderError(context, countries, country, phone, "Country is required");
            return;
        }
        if (phone == null) {
            renderError(context, countries, country, phone, "Phone number is required");
            return;
        }
        // Example rule: digits only
        String digits = phone.replaceAll("\\s+", "");
        if (!digits.matches("^\\+?[0-9]{8,15}$") && !digits.matches("^[0-9]{8,15}$")) {
            renderError(context, countries, country, phone, "Invalid phone format");
            return;
        }

        try {
            // 1) Store in Keycloak DB (create user if needed)
            RealmModel realm = context.getRealm();
            String username = normalizePhoneForUsername(country, phone); // e.g. +91 + phone

            UserModel user = context.getSession().users().getUserByUsername(realm, username);
            if (user == null) {
                user = context.getSession().users().addUser(realm, username);
                user.setEnabled(true);
                user.setUsername(username);
            }

            // Save attributes in Keycloak DB
            user.setSingleAttribute("country_code", country);
            user.setSingleAttribute("phone", normalizePhoneForUsername(country, phone));

            // Make Keycloak aware which user is logging in
            context.setUser(user);

            // 2) Store in Laravel DB (HTTP API)
            LaravelResponse resp = storePhoneInLaravel(country, phone);

            if (!resp.success) {
                // Dynamic message from Laravel → show in FTL
                renderError(context, countries, country, phone,
                    (resp.message != null && !resp.message.isBlank())
                        ? resp.message
                        : "Unable to store data in Laravel");
                return;
            }

        } catch (Exception e) {
            renderError(context, countries, country, phone, "Unable to save data. Please try again.");
            return;
        }

        // ✅ Success → Keycloak moves to next execution step in the flow
        context.success();
    }

    // ---------------------------
    // Helper: render error in FTL
    // ---------------------------
    private void renderError(AuthenticationFlowContext context,
                             List<Map<String, Object>> countries,
                             String country,
                             String phone,
                             String errorMsg) {

        context.failureChallenge(
            AuthenticationFlowError.INVALID_CREDENTIALS,
            context.form()
                .setAttribute("countries", countries)     // so dropdown renders again
                .setAttribute("countryValue", country)    // preserve selection
                .setAttribute("phoneValue", phone)        // preserve input
                .setError(errorMsg)
                .createForm(FTL_PHONE_LOGIN)
        );
    }

    // ---------------------------
    // Laravel: GET countries
    // ---------------------------
    @SuppressWarnings("unchecked")
    private List<Map<String, Object>> fetchCountriesFromLaravel() throws Exception {
        URL url = new URL(LARAVEL_BASE_URL + "/meta/countries");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();

        conn.setRequestMethod("GET");
        conn.setRequestProperty("Accept", "application/json");
        conn.setRequestProperty("Authorization", "Bearer " + INTERNAL_SECRET);

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

        String body = readAll(is);

        Map<String, Object> json = JsonSerialization.readValue(body, Map.class);

        Object data = json.get("data");
        if (!(data instanceof List)) {
            return Collections.emptyList();
        }
        return (List<Map<String, Object>>) data;
    }

    // ---------------------------
    // Laravel: POST store phone
    // ---------------------------
    private LaravelResponse storePhoneInLaravel(String country, String phone) throws Exception {
        URL url = new URL(LARAVEL_BASE_URL + "/auth/store-phone");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();

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

        String payload = "{\"country\":\"" + escape(country) + "\",\"phone\":\"" + escape(phone) + "\"}";

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

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

        String body = readAll(is);

        // Expect JSON: {"success":true,"message":"..."}
        @SuppressWarnings("unchecked")
        Map<String, Object> json = JsonSerialization.readValue(body, Map.class);

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

        return new LaravelResponse(success, message);
    }

    private static class LaravelResponse {
        final boolean success;
        final String message;
        LaravelResponse(boolean success, String message) {
            this.success = success;
            this.message = message;
        }
    }

    // ---------------------------
    // Utilities
    // ---------------------------
    private String readAll(InputStream is) throws Exception {
        if (is == null) return "";
        try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
            return br.lines().collect(Collectors.joining());
        }
    }

    private String trimToNull(String v) {
        if (v == null) return null;
        v = v.trim();
        return v.isEmpty() ? null : v;
    }

    private String deriveBaseUrl(String redirectUri) {
        if (redirectUri == null) return null;
        try {
            java.net.URI u = java.net.URI.create(redirectUri);
            if (u.getScheme() == null || u.getHost() == null) return null;
            return u.getScheme() + "://" + u.getHost();
        } catch (Exception e) {
            return null;
        }
    }

    private String normalizePhoneForUsername(String country, String phone) {
        String p = phone.replaceAll("\\s+", "");
        if (!p.startsWith("+")) p = "+" + p.replaceFirst("^\\+", "");
        // If phone already includes country, keep it; else prefix it
        if (p.startsWith(country)) return p;
        String c = country.startsWith("+") ? country : ("+" + country);
        // strip leading + from phone to avoid ++
        String phoneNoPlus = p.startsWith("+") ? p.substring(1) : p;
        return c + phoneNoPlus;
    }

    private String escape(String s) {
        if (s == null) return "";
        return s.replace("\\", "\\\\").replace("\"", "\\\"");
    }

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

Top comments (0)