Ir al contenido

Spring SecurityLección 11

Llamadas seguras entre servicios

Cuando un servicio llama a otro, ¿con qué identidad lo hace? Client Credentials, propagación del token del usuario, token exchange y mTLS, con RestClient y las interfaces HTTP de Spring.

Avanzado18 min de lectura

Al terminar sabrás

  • Elegir entre identidad de servicio, propagación del token del usuario y token exchange.
  • Configurar un cliente OAuth2 con Client Credentials para un job o un microservicio.
  • Añadir tokens automáticamente con OAuth2ClientHttpRequestInterceptor y @ClientRegistrationId.
  • Aplicar mínimo privilegio y confianza cero entre servicios.

En una arquitectura con varios servicios, las peticiones no solo vienen de usuarios: un job nocturno pide notas para un informe, un servicio de facturación consulta otro de clientes, un BFF llama a la API en nombre del usuario. Cada llamada necesita responder la misma pregunta de siempre —¿quién eres?— y la respuesta no es “estoy dentro de la red, confía en mí”.

PatrónQuién aparece como subCuándo
Client CredentialsEl propio servicio (client_id)Jobs, procesos sin usuario, llamadas de sistema
Propagación del token (token relay)El usuario originalEl servicio actúa en nombre del usuario y el destino acepta la misma audiencia
Token exchange (RFC 8693)El usuario, con un token nuevoCuando el destino tiene otra audiencia o necesitas recortar permisos
reports-jobcliente confidencialAuthorization Servernotes-api1POST /oauth2/tokengrant_type=client_credentials, scope=notes:read2access_token (sub = reports-job)3GET /api/notes + Bearer4200 OKLas siguientes llamadas reutilizan el token hasta que caduque
Client Credentials: el job obtiene un token propio y lo reutiliza hasta que caduca.

Con spring-boot-starter-security-oauth2-client, el registro del cliente son propiedades:

application.yml (reports-job)
spring:
security:
oauth2:
client:
registration:
notes-api:
provider: auth
client-id: reports-job
client-secret: ${REPORTS_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: notes:read
provider:
auth:
issuer-uri: http://localhost:9000

En un proceso sin petición HTTP de por medio (un job, un consumidor de mensajes), usa un AuthorizedClientServiceOAuth2AuthorizedClientManager, que no depende de la sesión del usuario:

NotesClientConfig.java
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clients) {
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
manager.setAuthorizedClientProvider(OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build());
return manager;
}
@Bean
RestClient notesClient(RestClient.Builder builder, OAuth2AuthorizedClientManager manager) {
return builder
.baseUrl("http://localhost:8080")
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(manager)) // añade el Bearer
.build();
}

Y en cada llamada indicas qué registro usar:

ReportJob.java
List<NoteView> notes = notesClient.get()
.uri("/api/notes")
.attributes(clientRegistrationId("notes-api")) // RequestAttributeClientRegistrationIdResolver
.retrieve()
.body(new ParameterizedTypeReference<>() { });

El interceptor pide el token la primera vez, lo reutiliza hasta que caduca y lo renueva solo.

Si declaras tus clientes como interfaces HTTP de Spring Framework 7, la anotación @ClientRegistrationId sustituye al atributo:

NotesApi.java
@HttpExchange("/api")
@ClientRegistrationId("notes-api")
interface NotesApi {
@GetExchange("/notes")
List<NoteView> list();
}
NotesClientConfig.java
@Bean
OAuth2RestClientHttpServiceGroupConfigurer securityConfigurer(OAuth2AuthorizedClientManager manager) {
return OAuth2RestClientHttpServiceGroupConfigurer.from(manager); // aplica el interceptor a los grupos de clientes HTTP
}

Si una API recibe una petición de ana y necesita llamar a otra en su nombre, puede reenviar el mismo token:

TokenRelay.java
ClientHttpRequestInterceptor relay = (request, body, execution) -> {
if (SecurityContextHolder.getContext().getAuthentication() instanceof JwtAuthenticationToken jwt) {
request.getHeaders().setBearerAuth(jwt.getToken().getTokenValue());
}
return execution.execute(request, body);
};
  • Cada servicio es un resource server: valida tokens aunque la llamada venga de “dentro”. La red interna no es una frontera de confianza.
  • Un cliente por servicio, con los scopes mínimos que necesita. reports-job lee; no escribe.
  • Distingue tokens de servicio y de usuario cuando importa: un token client_credentials no tiene un usuario detrás, así que reglas como “solo el dueño” no aplican igual.
  • mTLS (TLS mutuo) añade autenticación del canal: cada servicio presenta un certificado. Es habitual con un service mesh, y se combina con los tokens, no los sustituye.

Mini lab · 30 min

Un job que lee notas

Objetivo: Crear un proceso que obtenga un token con Client Credentials y lea la API de notas con permisos mínimos.

Usa el Authorization Server de la lección de OAuth2 con un cliente reports-job (client_credentials, scope notes:read).

  1. Crea una aplicación sin servidor web (spring.main.web-application-type=none) con el starter de cliente OAuth2 y la configuración de arriba.
  2. En un CommandLineRunner, llama a GET /api/notes dos veces seguidas y registra en el log cuándo se pide el token.
  3. Intenta un POST /api/notes con el mismo cliente.

Preguntas:

  • ¿Cuántas veces se llama a /oauth2/token en el paso 2? ¿Por qué?
  • ¿Qué responde la API al POST y por qué?
  • Si la API filtra las notas por owner = authentication.name, ¿qué notas ve el job? ¿Cómo darías al job acceso de lectura a todas sin darle permisos de admin humano?
Pista

Mira el sub del token de client_credentials y el scope que se le concedió.

Mostrar respuestaOcultar respuesta
  • Una. El OAuth2AuthorizedClientManager guarda el cliente autorizado (con su token) en el OAuth2AuthorizedClientService y lo reutiliza hasta que está a punto de caducar.
  • 403: el token solo tiene SCOPE_notes:read y la regla de escritura exige SCOPE_notes:write. Es el mínimo privilegio funcionando.
  • Ninguna: su sub es reports-job, que no es dueño de nada. Crea un scope específico (por ejemplo notes:report) que solo se conceda a ese cliente, y un endpoint o regla (hasAuthority("SCOPE_notes:report")) que permita leerlas todas, separado de los permisos de los administradores humanos.