KeycloakClientFactory.java

/*
 * Copyright 2026 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.bremersee.keycloak.api.webflux;

import static java.util.Objects.nonNull;
import static java.util.Objects.requireNonNullElse;

import com.jayway.jsonpath.Configuration;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.Option;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import org.springframework.cloud.openfeign.support.SpringMvcContract;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactivefeign.ReactiveFeign;
import reactivefeign.client.ReactiveHttpRequest;
import reactivefeign.client.ReactiveHttpRequestInterceptor;
import reactivefeign.client.ReactiveHttpRequestInterceptors;
import reactivefeign.webclient.WebClientFeignCustomizer;
import reactivefeign.webclient.WebReactiveFeign;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
import reactor.util.function.Tuples;

/**
 * The keycloak client factory.
 *
 * @author Christian Bremer
 */
public class KeycloakClientFactory {

  private final String keycloakBaseUri;

  private final String loginRealm;

  private final String clientId;

  private final String username;

  private final String password;

  @Getter(AccessLevel.PROTECTED)
  @Setter
  private WebClientFeignCustomizer webClientBuilderCustomizer;

  @Getter(AccessLevel.PROTECTED)
  @Setter
  private Consumer<ReactiveFeign.Builder<AdminApi>> adminApiCustomizer;

  /**
   * Instantiates a new Keycloak client factory.
   *
   * @param keycloakBaseUri the keycloak base uri
   * @param loginRealm the login realm
   * @param clientId the client id
   * @param username the username
   * @param password the password
   */
  public KeycloakClientFactory(
      String keycloakBaseUri,
      String loginRealm,
      String clientId,
      String username,
      String password) {

    Assert.hasText(keycloakBaseUri, "keycloakBaseUri must not be null or empty.");
    Assert.hasText(username, "username must not be null or empty.");
    Assert.hasText(password, "password must not be null or empty.");
    this.keycloakBaseUri = keycloakBaseUri;
    this.loginRealm = requireNonNullElse(loginRealm, "master");
    this.clientId = requireNonNullElse(clientId, "admin-cli");
    this.username = username;
    this.password = password;
  }

  /**
   * New client admin api.
   *
   * @return the admin api
   */
  public AdminApi newClient() {
    ReactiveFeign.Builder<AdminApi> builder = WebReactiveFeign
        .<AdminApi>builder(WebClient.builder(), webClientBuilderCustomizer)
        .contract(new SpringMvcContract())
        .addRequestInterceptor(
            ReactiveHttpRequestInterceptors.addHeader("Cache-Control", "no-cache"))
        .addRequestInterceptor(getLoginInterceptor());
    if (nonNull(adminApiCustomizer)) {
      adminApiCustomizer.accept(builder);
    }
    return builder.target(AdminApi.class, keycloakBaseUri);
  }

  /**
   * Gets login interceptor.
   *
   * @return the login interceptor
   */
  protected ReactiveHttpRequestInterceptor getLoginInterceptor() {
    return new LoginInterceptor(
        keycloakBaseUri, loginRealm, clientId, username, password, webClientBuilderCustomizer);
  }

  /**
   * The login interceptor.
   *
   * @author Christian Bremer
   */
  private static class LoginInterceptor implements ReactiveHttpRequestInterceptor {

    private static final Configuration jsonPathConf = Configuration.builder()
        .options(Option.SUPPRESS_EXCEPTIONS)
        .build();

    private final WebClient webClient;

    private final MultiValueMap<String, String> body = new LinkedMultiValueMap<>();

    private Tuple2<String, Instant> lastAccessToken;

    /**
     * Instantiates a new Login interceptor.
     *
     * @param keycloakBaseUri the keycloak base uri
     * @param loginRealm the login realm
     * @param clientId the client id
     * @param username the username
     * @param password the password
     * @param webClientBuilderCustomizer the web client builder customizer
     */
    private LoginInterceptor(
        String keycloakBaseUri,
        String loginRealm,
        String clientId,
        String username,
        String password,
        Consumer<WebClient.Builder> webClientBuilderCustomizer) {

      WebClient.Builder builder = WebClient.builder()
          .baseUrl(keycloakBaseUri + "/realms/{loginRealm}/protocol/openid-connect/token")
          .defaultUriVariables(Map.of("loginRealm", loginRealm))
          .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);

      if (nonNull(webClientBuilderCustomizer)) {
        webClientBuilderCustomizer.accept(builder);
      }
      webClient = builder.build();
      body.add("client_id", clientId);
      body.add("grant_type", "password");
      body.add("username", username);
      body.add("password", password);
    }

    private synchronized Mono<String> getLastAccessToken() {
      return Mono.justOrEmpty(lastAccessToken)
          .filter(tuple -> tuple.getT2()
              .isAfter(Instant.now().plusSeconds(30L)))
          .map(Tuple2::getT1);
    }

    private synchronized void setLastAccessToken(
        Tuple2<String, Instant> lastAccessToken) {
      this.lastAccessToken = lastAccessToken;
    }

    private Mono<String> getFreshAccessToken() {
      return webClient
          .post()
          .body(BodyInserters.fromFormData(body))
          .retrieve()
          .bodyToMono(String.class)
          .flatMap(json -> {
            DocumentContext documentContext = JsonPath.parse(json, jsonPathConf);
            return Mono.justOrEmpty(documentContext.read("$.access_token", String.class))
                .map(token -> {
                  Optional.ofNullable(documentContext.read("$.expires_in", Long.class))
                      .ifPresent(expiresIn -> setLastAccessToken(
                          Tuples.of(token, Instant.now().plusSeconds(expiresIn))));
                  return token;
                });
          });
    }

    @Override
    public Mono<ReactiveHttpRequest> apply(ReactiveHttpRequest reactiveHttpRequest) {
      return getLastAccessToken()
          .singleOptional()
          .flatMap(optAccessToken -> optAccessToken
              .map(Mono::just)
              .orElseGet(this::getFreshAccessToken))
          .map(accessToken -> {
            reactiveHttpRequest.headers()
                .put(HttpHeaders.AUTHORIZATION, List.of("Bearer " + accessToken));
            return reactiveHttpRequest;
          });
    }
  }

}