Saltar a contenido

Client Credentials

El flujo Client Credentials es el estándar para comunicación machine-to-machine (M2M): servicios backend, jobs programados, microservicios y cualquier sistema que actúe en su propio nombre, sin usuario interactivo.

Cuándo usarlo

  • Microservicio A llama a microservicio B de forma programática.
  • Job nocturno que consume una API protegida.
  • Sistema externo de un socio que consulta datos vía API.
  • Cualquier integración sin intervención humana.

No usar para: aplicaciones con usuario interactivo → usar Authorization Code + PKCE.

Diagrama del flujo

sequenceDiagram
    autonumber
    participant S as Servicio M2M
    participant K as Keycloak
    participant API as Resource Server

    S->>K: POST /realms/realm-partners/protocol/openid-connect/token\ngrant_type=client_credentials\nclient_id=partner-service-m2m\nclient_secret=<secret>
    K->>K: Verificar client_id y client_secret\nVerificar que Service Accounts están habilitados
    K-->>S: access_token (JWT) · expires_in: 300
    S->>API: GET /api/v1/datos\nAuthorization: Bearer <access_token>
    API->>API: Verificar firma JWT (JWKS)\nVerificar iss, aud, exp\nVerificar roles del service account
    API-->>S: 200 OK datos
    Note over S,API: Cuando el token expira, repetir desde paso 1

Obtener un token con curl

KC_URL="https://auth.example.com"
REALM="realm-partners"
CLIENT_ID="partner-service-m2m"
CLIENT_SECRET="<secret-del-cliente>"

curl -s -X POST \
  "${KC_URL}/realms/${REALM}/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=${CLIENT_ID}" \
  -d "client_secret=${CLIENT_SECRET}" \
  -d "scope=api:read api:write" | jq .

Respuesta

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 300,
  "token_type": "Bearer",
  "scope": "api:read api:write"
}

Sin refresh token

El flujo Client Credentials no emite refresh token. Cuando el access token expira, el servicio debe solicitar uno nuevo. Implementa una caché del token con renovación automática cuando expires_in - 30 segundos se cumpla.

Configuración del cliente en Keycloak

Cliente: partner-service-m2m
Tipo de acceso: Confidential
Service Accounts Enabled: ✅
Standard Flow: ❌
Direct Access Grants: ❌

Roles asignados al Service Account:
  - api:read
  - api:write
  (en la pestaña "Service Account Roles")

Implementación en Java con Spring WebFlux

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient apiClient(
            @Value("${app.keycloak.token-uri}") String tokenUri,
            @Value("${app.keycloak.client-id}") String clientId,
            @Value("${app.keycloak.client-secret}") String clientSecret) {

        ClientRegistration registration = ClientRegistration
                .withRegistrationId("partner-service-m2m")
                .tokenUri(tokenUri)
                .clientId(clientId)
                .clientSecret(clientSecret)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .scope("api:read", "api:write")
                .build();

        ReactiveClientRegistrationRepository registrationRepo =
                new InMemoryReactiveClientRegistrationRepository(registration);

        ServerOAuth2AuthorizedClientManager manager =
                new UnAuthenticatedServerOAuth2AuthorizedClientManager(
                        registrationRepo,
                        new UnAuthenticatedReactiveOAuth2AuthorizedClientService(registrationRepo));

        ServerBearerExchangeFilterFunction oauth2 =
                new ServerBearerExchangeFilterFunction(manager);

        return WebClient.builder()
                .baseUrl("https://api.example.com")
                .filter(oauth2)
                .build();
    }
}
@Service
@RequiredArgsConstructor
public class PartnerApiService {

    private final WebClient apiClient;

    public Flux<PartnerData> fetchData() {
        return apiClient.get()
                .uri("/api/v1/datos")
                .attributes(ServerOAuth2AuthorizedClientExchangeFilterFunction
                        .clientRegistrationId("partner-service-m2m"))
                .retrieve()
                .bodyToFlux(PartnerData.class);
    }
}

Gestión del token en producción

// Caché manual con renovación automática
@Component
public class TokenCache {

    private final WebClient tokenClient = WebClient.create();
    private volatile String cachedToken;
    private volatile Instant expiresAt = Instant.EPOCH;

    public Mono<String> getToken(String tokenUri, String clientId, String clientSecret) {
        if (cachedToken != null && Instant.now().isBefore(expiresAt.minusSeconds(30))) {
            return Mono.just(cachedToken);
        }
        return fetchNewToken(tokenUri, clientId, clientSecret);
    }

    private Mono<String> fetchNewToken(String tokenUri, String clientId, String secret) {
        return tokenClient.post().uri(tokenUri)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .bodyValue("grant_type=client_credentials&client_id=" + clientId
                        + "&client_secret=" + secret)
                .retrieve()
                .bodyToMono(TokenResponse.class)
                .doOnNext(r -> {
                    cachedToken = r.accessToken();
                    expiresAt = Instant.now().plusSeconds(r.expiresIn());
                })
                .map(TokenResponse::accessToken);
    }
}

Proteger el client secret

El client secret es equivalente a una contraseña. Nunca lo incluyas en el código fuente, en logs o en variables de entorno visibles. Usa un gestor de secretos (.env en dev, Vault o External Secrets en producción) y rótalo periódicamente.