Saltar a contenido

Secuencia — Client Credentials

El flujo Client Credentials es el estándar OAuth 2.0 para comunicación máquina a máquina (M2M) donde no hay intervención de usuario. El cliente se autentica directamente con sus credenciales (client_id + client_secret) y obtiene un access token para consumir APIs protegidas. En esta plataforma, el cliente partner-service-m2m utiliza este flujo para que servicios backend de socios consuman la API de integración.

Diagrama de secuencia

sequenceDiagram
    autonumber
    participant Svc as Servicio Backend<br/>(partner-service-m2m)
    participant Traefik as Traefik v3<br/>:443
    participant KC as Keycloak 26<br/>realm-partners
    participant API as Resource API<br/>(Spring WebFlux)

    Note over Svc: Verificación de token en caché
    Svc->>Svc: ¿Tiene access_token válido en caché?<br/>exp - 30s > now()

    alt Token en caché válido
        Note over Svc: Reutiliza token existente
        Svc->>Svc: Usa access_token del caché
    else Sin token o expirado
        Note over Svc,KC: Solicitud de nuevo token
        Svc->>Traefik: POST /realms/realm-partners/protocol/openid-connect/token<br/>Content-Type: application/x-www-form-urlencoded<br/>Authorization: Basic BASE64(client_id:client_secret)<br/>Body: grant_type=client_credentials&scope=api:read api:write
        Traefik->>KC: Proxy POST (TLS terminado en Traefik)

        Note over KC: Validación de credenciales del cliente
        KC->>KC: Verifica client_id existe en realm-partners<br/>Verifica client_secret (hash bcrypt)<br/>Verifica que el cliente tiene el grant_type habilitado<br/>Verifica scopes solicitados vs scopes permitidos

        KC-->>Traefik: 200 OK
        Traefik-->>Svc: JSON con access_token (JWT)<br/>expires_in: 300<br/>token_type: Bearer<br/>scope: api:read api:write

        Svc->>Svc: Guarda token en caché local<br/>con TTL = expires_in - 30s
    end

    Note over Svc,API: Llamada a la API protegida
    Svc->>Traefik: GET /api/v1/partners/data<br/>Authorization: Bearer ACCESS_TOKEN_JWT
    Traefik->>API: Proxy con header Bearer intacto

    Note over API: Validación del JWT
    API->>API: Verifica firma RS256 (JWKS cache)<br/>Verifica iss == https://kc.empresa.com/realms/realm-partners<br/>Verifica aud incluye la API<br/>Verifica exp > now()<br/>Verifica claims de servicio (azp, scope)

    alt JWT válido
        API-->>Traefik: 200 OK — Datos del recurso
        Traefik-->>Svc: Respuesta JSON
    else JWT inválido o expirado
        API-->>Traefik: 401 Unauthorized<br/>WWW-Authenticate: Bearer error="invalid_token"
        Traefik-->>Svc: 401 Unauthorized
        Svc->>Svc: Invalida caché, reintenta flujo
    end

Parámetros del Token Request

Parámetro Valor Obligatorio Descripción
grant_type client_credentials Tipo de grant M2M
scope api:read api:write Recomendado Scopes solicitados; deben estar configurados en el cliente
client_id partner-service-m2m Sí* En body si no se usa Basic Auth
client_secret s3cr3t-value-rotate-periodically Secreto del cliente confidencial

*La autenticación del cliente puede ser en header Authorization: Basic (recomendado) o en el body como client_id + client_secret.

Respuesta del Token Endpoint

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI0In0.eyJpc3MiOiJodHRwczovL2tjLmVtcHJlc2EuY29tL3JlYWxtcy9yZWFsbS1wYXJ0bmVycyIsInN1YiI6ImE4YjljMTBkIiwiYXpwIjoicGFydG5lci1zZXJ2aWNlLW0ybSIsInNjb3BlIjoiYXBpOnJlYWQgYXBpOndyaXRlIiwiZXhwIjoxNzIyMDAwMzAwfQ...",
  "expires_in": 300,
  "token_type": "Bearer",
  "scope": "api:read api:write",
  "not-before-policy": 0
}

Sin refresh_token en Client Credentials

Este flujo no emite refresh token. Cuando el access token expira, el servicio simplemente solicita uno nuevo con sus credenciales. No hay estado de sesión de usuario que preservar.

Claims del JWT para clientes M2M

{
  "iss": "https://kc.empresa.com/realms/realm-partners",
  "sub": "a8b9c10d-1234-5678-abcd-ef0123456789",
  "azp": "partner-service-m2m",
  "aud": ["partner-api", "account"],
  "scope": "api:read api:write",
  "exp": 1722000300,
  "iat": 1722000000,
  "jti": "unique-token-id-abcd1234",
  "realm_access": {
    "roles": ["service-account-partner-service-m2m"]
  },
  "resource_access": {
    "partner-api": {
      "roles": ["data-reader", "data-writer"]
    }
  }
}

Ejemplo con curl

# Solicitar access token con Client Credentials
TOKEN=$(curl -s \
  -X POST "https://kc.empresa.com/realms/realm-partners/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "partner-service-m2m:${CLIENT_SECRET}" \
  -d "grant_type=client_credentials" \
  -d "scope=api:read api:write" \
  | jq -r '.access_token')

echo "Token obtenido: ${TOKEN:0:50}..."

# Usar el token para llamar a la API
curl -s \
  -X GET "https://api.empresa.com/v1/partners/data" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Accept: application/json" \
  | jq .
# Script completo con manejo de caché y rotación automática
#!/bin/bash
TOKEN_FILE="/tmp/.m2m_token_cache"
TOKEN_EXPIRY_FILE="/tmp/.m2m_token_expiry"

get_token() {
  local now
  now=$(date +%s)

  # Revisar caché: si el token vence en más de 30 segundos, reutilizarlo
  if [[ -f "$TOKEN_FILE" && -f "$TOKEN_EXPIRY_FILE" ]]; then
    local expiry
    expiry=$(cat "$TOKEN_EXPIRY_FILE")
    if (( expiry - now > 30 )); then
      cat "$TOKEN_FILE"
      return 0
    fi
  fi

  # Solicitar nuevo token
  local response
  response=$(curl -s \
    -X POST "https://kc.empresa.com/realms/realm-partners/protocol/openid-connect/token" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -u "${CLIENT_ID}:${CLIENT_SECRET}" \
    -d "grant_type=client_credentials&scope=api:read api:write")

  local token expires_in
  token=$(echo "$response" | jq -r '.access_token')
  expires_in=$(echo "$response" | jq -r '.expires_in')

  echo "$token" > "$TOKEN_FILE"
  echo $(( now + expires_in )) > "$TOKEN_EXPIRY_FILE"
  echo "$token"
}

TOKEN=$(get_token)
curl -s "https://api.empresa.com/v1/partners/data" \
  -H "Authorization: Bearer ${TOKEN}"

Configuración en Spring WebFlux (cliente M2M)

// Configuración del cliente HTTP con renovación automática de tokens
@Configuration
public class M2MWebClientConfig {

    @Bean
    public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
            ReactiveClientRegistrationRepository clientRegistrationRepository,
            ServerOAuth2AuthorizedClientRepository authorizedClientRepository) {

        var provider = ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials()
            .build();

        var manager = new DefaultReactiveOAuth2AuthorizedClientManager(
            clientRegistrationRepository,
            authorizedClientRepository);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    public WebClient partnerApiWebClient(ReactiveOAuth2AuthorizedClientManager manager) {
        var filter = new ServerOAuth2AuthorizedClientExchangeFilterFunction(manager);
        filter.setDefaultClientRegistrationId("partner-service-m2m");

        return WebClient.builder()
            .baseUrl("https://api.empresa.com")
            .filter(filter)
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }
}
# application.yml — registro del cliente OAuth2
spring:
  security:
    oauth2:
      client:
        registration:
          partner-service-m2m:
            client-id: partner-service-m2m
            client-secret: ${M2M_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: api:read, api:write
        provider:
          keycloak-partners:
            token-uri: https://kc.empresa.com/realms/realm-partners/protocol/openid-connect/token

Cuándo usar Client Credentials vs otros flujos

Criterio Client Credentials Authorization Code + PKCE Refresh Token
Intervención de usuario No No (después del login inicial)
Tipo de cliente Servicio backend confidencial SPA / app pública Cualquier cliente con sesión
Guarda secreto Sí (variable de entorno) No No aplica (es un complemento)
Refresh token emitido No Sí (con offline_access) N/A
Caso de uso típico Cron jobs, microservicios, ETL Portal web de usuario Mantener sesión activa
Duración del access token Corta (5 min) Corta (5 min) Permite renovar sin re-login

Rotación de client_secret

El client_secret debe rotarse periódicamente (cada 90 días como mínimo) y almacenarse como secreto de Docker o variable de entorno cifrada. Nunca lo incluyas en código fuente, imágenes Docker o logs. Keycloak soporta múltiples secretos simultáneos durante la transición.

No uses Client Credentials en clientes públicos

Las SPAs y aplicaciones móviles no pueden proteger secretos. Si necesitas M2M desde el frontend, usa un BFF (Backend for Frontend) que centralice las credenciales y exponga una sesión autenticada al navegador.

Monitoreo de tokens M2M

Configura alertas en Keycloak Events para detectar fallos repetidos de autenticación de clientes. Un número elevado de errores client_credentials puede indicar una credencial comprometida o rotación pendiente.