When you run the same Laravel code on multiple domains like:
motoshare.in
motoshare.jp
motoshare.us
you usually want:
One Keycloak realm
One Keycloak client
One custom authentication flow (WhatsApp/SMS OTP + Email fallback)
But UI + API calls should be domain-aware (JP vs US vs IN)
The key idea is simple:
- Laravel generates a domain-specific redirect_uri (based on current host).
- Keycloak stores that redirect_uri inside the Authentication Session.
- Your Keycloak Java SPI reads that redirect_uri to know which domain started the login.
- You compute the correct Laravel API base URL (api.motoshare.jp etc.) and store it in authSession notes so all authenticators reuse it.
- You render one theme but show conditional FTL UI based on the domain/tenant.
Result: same JAR + same flow works across all domains.
What you will build
A single Keycloak login flow that supports:
Phone login → WhatsApp/SMS OTP
If phone not found → Email capture + Email OTP verify
Works across multiple domains using one Keycloak client
Calls the correct Laravel API automatically based on the domain
Step 0: Assumptions (example)
Keycloak: http://localhost:8080
Realm: motoshare
Client ID: motoshare
Laravel runs on each domain: https://motoshare.in, https://motoshare.jp, https://motoshare.us
Laravel callback route: /auth/keycloak/callback
Laravel API per domain: https://api.motoshare.in, https://api.motoshare.jp, https://api.motoshare.us
If your API URL pattern is different, you will change one line later.
Step 1: Make Laravel redirect_uri dynamic (per domain)
Why this is required
If you keep a fixed redirect URI in .env, Keycloak cannot distinguish the original domain.
So we generate it using the current request host.
Laravel code: dynamic redirect URI
private function redirectUri(Request $request): string
{
return rtrim($request->getSchemeAndHttpHost(), '/') . '/auth/keycloak/callback';
}
Laravel code: redirect to Keycloak (PKCE)
public function redirect(Request $request)
{
$state = Str::random(40);
$verifier = $this->buildPkceVerifier();
$challenge = $this->buildPkceChallenge($verifier);
$redirectUri = $this->redirectUri($request);
// Safer return URL (same-host only)
$return = $request->query('return');
if (!$return || !str_starts_with($return, $request->getSchemeAndHttpHost())) {
$return = $request->getSchemeAndHttpHost() . '/';
}
session([
'kc_state' => $state,
'kc_verifier' => $verifier,
'kc_return' => $return,
'kc_redirect_uri' => $redirectUri,
]);
$params = http_build_query([
'client_id' => $this->clientId(),
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'openid email profile',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
return redirect()->away($this->kcUrl('/protocol/openid-connect/auth') . '?' . $params);
}
Step 2: Make Laravel token exchange use the same redirect URI
Keycloak requires the exact same redirect_uri during token exchange.
Laravel callback snippet
$payload = [
'grant_type' => 'authorization_code',
'client_id' => $this->clientId(),
'redirect_uri' => session('kc_redirect_uri'),
'code' => $request->code,
'code_verifier' => session('kc_verifier'),
];
if ($this->clientSecret()) {
$payload['client_secret'] = $this->clientSecret();
}
Step 3: Configure Keycloak client for multiple redirect URIs
In Keycloak Admin:
Clients → motoshare → Settings → Valid redirect URIs
Add all:
https://motoshare.in/auth/keycloak/callback
https://motoshare.jp/auth/keycloak/callback
https://motoshare.us/auth/keycloak/callback
(Also add local dev URLs if needed.)
Step 4: Read the domain inside Keycloak Java SPI
Keycloak stores redirect_uri inside the authentication session.
That is the safest way to detect the origin domain.
Java helper: get request base URL from redirect URI
import java.net.URI;
private String resolveRequestBaseUrl(AuthenticationFlowContext context) {
String redirectUri = context.getAuthenticationSession().getRedirectUri();
if (redirectUri == null || redirectUri.isBlank()) {
return null;
}
URI uri = URI.create(redirectUri);
String scheme = uri.getScheme(); // https
String host = uri.getHost(); // motoshare.jp
int port = uri.getPort(); // usually -1
return scheme + "://" + host + (port > 0 ? ":" + port : "");
}
Example:
redirect_uri = https://motoshare.jp/auth/keycloak/callback
requestBase = https://motoshare.jp
Step 5: Build Laravel API base dynamically + store it (AuthSession notes)
You want to compute once and reuse for:
country list fetch
send OTP
verify OTP
verify email OTP
Java helper: compute and cache base URLs
private String resolveLaravelApiBase(AuthenticationFlowContext context) {
AuthenticationSessionModel s = context.getAuthenticationSession();
// ✅ reuse if already computed
String cached = s.getAuthNote("API_BASE_URL");
if (cached != null && !cached.isBlank()) return cached;
String requestBase = resolveRequestBaseUrl(context); // https://motoshare.jp
if (requestBase == null) requestBase = "https://motoshare.in"; // fallback
// ✅ your mapping rule (change if needed)
String apiBase = requestBase.replace("https://", "https://api.");
// ✅ store for all next steps
s.setAuthNote("REQ_BASE_URL", requestBase);
s.setAuthNote("API_BASE_URL", apiBase);
System.out.println("REQ_BASE_URL=" + requestBase);
System.out.println("API_BASE_URL=" + apiBase);
return apiBase;
}
Step 6: Use this base URL in authenticate() (Country list + UI)
@Override
public void authenticate(AuthenticationFlowContext context) {
String apiBase = resolveLaravelApiBase(context);
List<Map<String, String>> countries;
try {
CountryFetcher fetcher = new CountryFetcher(apiBase);
countries = fetcher.fetchCountriesFromLaravel();
} catch (Exception e) {
countries = new ArrayList<>();
}
// Optional tenant flag for UI
String tenant = apiBase.contains(".jp") ? "JP" : (apiBase.contains(".us") ? "US" : "IN");
context.getAuthenticationSession().setAuthNote("TENANT", tenant);
Response challenge = context.form()
.setAttribute("tenant", tenant)
.setAttribute("countries", countries)
.createForm("phone-login.ftl");
context.challenge(challenge);
}
Step 7: Use the same API base URL in action() (Send OTP)
@Override
public void action(AuthenticationFlowContext context) {
String apiBase = resolveLaravelApiBase(context); // same base
var formData = context.getHttpRequest().getDecodedFormParameters();
String channel = safe(formData.getFirst("channel"));
String country = safe(formData.getFirst("country")); // +91
String phone = safe(formData.getFirst("phone"));
String phoneDigits = phone.replaceAll("\\D+", "");
String ccDigits = country.replaceAll("\\D+", "");
if (ccDigits.length() < 1 || phoneDigits.length() < 6) {
context.failureChallenge(
AuthenticationFlowError.INVALID_USER,
context.form().setError("Invalid phone number.").createForm("phone-login.ftl")
);
return;
}
String fullPhone = "+" + ccDigits + phoneDigits;
var s = context.getAuthenticationSession();
s.setAuthNote("PHONE_OTP_CHANNEL", channel);
s.setAuthNote("PHONE_OTP_PHONE", fullPhone);
s.setAuthNote("PHONE_OTP_TS", String.valueOf(System.currentTimeMillis()));
s.setAuthNote("PHONE_DIAL_CODE", ccDigits);
s.setAuthNote("PHONE_DIAL_CODE_RAW", country);
try {
OtpSender sender = new LaravelOtpSender(apiBase);
sender.send(fullPhone, null, channel);
} catch (Exception e) {
context.failureChallenge(
AuthenticationFlowError.INTERNAL_ERROR,
context.form().setError("Unable to send OTP. Please try again.").createForm("phone-login.ftl")
);
return;
}
context.success();
}
Step 8: Use conditional UI in FreeMarker (FTL)
phone-login.ftl
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=false; section>
<#if section = "form">
<#-- tenant passed from Java -->
<#if tenant == "JP">
<h3>日本向けログイン</h3>
<#elseif tenant == "US">
<h3>Login (US)</h3>
<#else>
<h3>Login (India)</h3>
</#if>
<form action="${url.loginAction}" method="post">
...
</form>
</#if>
</@layout.registrationLayout>
You can switch:
language strings
default channel (WhatsApp vs SMS)
show/hide email step
change logos/styles
Step 9: Build the Keycloak authentication flow chain
Your desired flow:
phone-login → phone_verify_otp → email_login → email_verify
In Keycloak Admin:
Authentication → Flows
Create a Browser flow like: Phone OTP Browser
Add executions in order:
PhoneInputAuthenticator (shows phone-login.ftl)
PhoneOtpVerifyAuthenticator (OTP verify)
EmailInputAuthenticator (only if phone user not found)
EmailOtpVerifyAuthenticator
Then set:
Realm Settings → Authentication → Browser Flow = Phone OTP Browser
Step 10: Result
✅ One Keycloak realm
✅ One client
✅ One Java plugin
✅ One theme
✅ Multiple domains supported
✅ API base and UI changes automatically per domain
Top comments (0)