Debug School

rakesh kumar
rakesh kumar

Posted on

How to Build One Keycloak Login Flow for Multiple Domains

When you run the same Laravel code on multiple domains like:

motoshare.in

motoshare.jp

motoshare.us
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure Keycloak client for multiple redirect URIs

In Keycloak Admin:

Clients → motoshare → Settings → Valid redirect URIs
Enter fullscreen mode Exit fullscreen mode

Add all:

https://motoshare.in/auth/keycloak/callback
https://motoshare.jp/auth/keycloak/callback
https://motoshare.us/auth/keycloak/callback
Enter fullscreen mode Exit fullscreen mode

(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 : "");
}
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)