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.
So flow is:
Keycloak authenticate()
↓
Java calls Laravel API
↓
Java puts country list into form attributes
↓
FTL renders dropdown
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" }
]
}
Endpoint:
GET https://api.motoshare.in/meta/countries
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");
}
This returns a Java list like:
[
{code=+91, name=India},
{code=+1, name=United States}
]
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);
}
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>
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)
FTL
<option value="${c.code}"
<#if selectedCountry?? && selectedCountry == c.code>selected</#if>>
${c.name} (${c.code})
</option>
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");
}
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();
}
4️⃣ How error shows in FTL (automatic)
Your FTL already has:
<#if message?has_content>
<div class="alert alert-error">
${message.summary}
</div>
</#if>
How to Store data in Keycloak database
UserModel user = context.getUser();
if (user != null) {
user.setSingleAttribute("phone", phone);
}
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);
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;
}
}
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() {}
}
✅ 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);
}
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"
}
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");
}
}
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();
}
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() {}
}
Top comments (0)