Backend for Frontend (BFF)¶
El patrón Backend for Frontend (BFF) introduce un componente de servidor dedicado que actúa como intermediario entre el navegador y los servicios de backend, incluyendo Keycloak. En esta plataforma, el BFF gestiona todo el flujo OAuth2 Authorization Code + PKCE en el servidor, de modo que los tokens de acceso nunca se exponen al código JavaScript del navegador. Esto elimina la superficie de ataque más habitual en aplicaciones SPA y reduce la complejidad de seguridad en el cliente.
Por qué BFF con Keycloak¶
Una SPA pura que implementa el Authorization Code Flow con PKCE recibe el access token en el navegador y lo almacena en memoria o localStorage. Cualquier script inyectado (XSS) puede exfiltrar ese token. El BFF invierte este modelo: el navegador solo mantiene una sesión HTTP con el BFF mediante una cookie HttpOnly, Secure, SameSite=Lax, mientras el BFF almacena los tokens en sesión de servidor o Redis.
| Aspecto | SPA pura | BFF |
|---|---|---|
| Tokens en browser | Access + Refresh token expuestos | Solo cookie de sesión opaca |
| Superficie XSS | Alta — token exfiltrable directamente | Baja — cookie HttpOnly no accesible desde JS |
| Renovación (refresh) | JS debe gestionar expiración manualmente | BFF renueva en background de forma transparente |
| CORS hacia Keycloak | Keycloak debe permitir el origen del browser | Solo el BFF hace requests a Keycloak |
| Logout coordinado | El browser debe llamar al endpoint de logout | BFF coordina RP-Initiated Logout con Keycloak |
Diagrama de flujo¶
sequenceDiagram
autonumber
participant B as Browser
participant BFF as BFF (Spring WebFlux)
participant KC as Keycloak (realm-partners)
participant API as API protegida
B->>BFF: GET /dashboard (sin sesión)
BFF->>B: 302 Redirect → /oauth2/authorization/keycloak
B->>BFF: GET /oauth2/authorization/keycloak
BFF->>B: 302 Redirect → Keycloak /authorize?code_challenge=XYZ&state=ABC
B->>KC: GET /authorize (usuario introduce credenciales)
KC->>B: 302 Redirect → /login/oauth2/code/keycloak?code=AUTH_CODE
B->>BFF: GET /login/oauth2/code/keycloak?code=AUTH_CODE
BFF->>KC: POST /token (code, code_verifier, client_secret)
KC->>BFF: access_token + refresh_token + id_token
BFF->>BFF: Almacena tokens en sesión de servidor (Redis/memoria)
BFF->>B: Set-Cookie: SESSION=opaque HttpOnly Secure SameSite=Lax
B->>BFF: GET /api/dashboard (cookie SESSION)
BFF->>API: GET /internal/dashboard (Authorization: Bearer access_token)
API->>BFF: 200 datos JSON
BFF->>B: 200 datos JSON (sin tokens en la respuesta)
El browser nunca recibe el access_token. En el paso 8, el BFF envía el client_secret almacenado en variables de entorno del contenedor Docker, nunca expuesto al frontend.
Implementación con Spring WebFlux¶
Dependencias (build.gradle)¶
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'io.lettuce:lettuce-core'
}
Configuración de seguridad¶
@Configuration
@EnableWebFluxSecurity
public class BffSecurityConfig {
@Bean
public SecurityWebFilterChain securityFilterChain(
ServerHttpSecurity http,
ReactiveClientRegistrationRepository clientRegistrations) {
OidcClientInitiatedServerLogoutSuccessHandler logoutHandler =
new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrations);
logoutHandler.setPostLogoutRedirectUri("{baseUrl}/");
return http
.authorizeExchange(auth -> auth
.pathMatchers("/actuator/health", "/public/**").permitAll()
.anyExchange().authenticated()
)
.oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults())
.logout(logout -> logout
.logoutSuccessHandler(logoutHandler)
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
)
.build();
}
@Bean
public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
ReactiveClientRegistrationRepository registrations,
ServerOAuth2AuthorizedClientRepository authorizedClients) {
ReactiveOAuth2AuthorizedClientProvider provider =
ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken(rt -> rt.clockSkew(Duration.ofSeconds(30)))
.build();
DefaultReactiveOAuth2AuthorizedClientManager manager =
new DefaultReactiveOAuth2AuthorizedClientManager(registrations, authorizedClients);
manager.setAuthorizedClientProvider(provider);
return manager;
}
}
Filtro de relay de tokens¶
El BFF actúa como proxy hacia las APIs internas añadiendo el token al header Authorization:
@Component
public class TokenRelayGatewayFilter implements WebFilter {
private final ServerOAuth2AuthorizedClientRepository authorizedClients;
public TokenRelayGatewayFilter(ServerOAuth2AuthorizedClientRepository authorizedClients) {
this.authorizedClients = authorizedClients;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> authorizedClients
.loadAuthorizedClient("keycloak", auth, exchange))
.map(client -> client.getAccessToken().getTokenValue())
.flatMap(token -> {
ServerHttpRequest request = exchange.getRequest().mutate()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.build();
return chain.filter(exchange.mutate().request(request).build());
})
.switchIfEmpty(chain.filter(exchange));
}
}
Configuración application.yml¶
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: partner-portal-app
client-secret: ${KEYCLOAK_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid,profile,email,roles
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://id.empresa.com/realms/realm-partners
user-name-attribute: preferred_username
session:
store-type: redis
redis:
namespace: bff:session
server:
servlet:
session:
cookie:
http-only: true
secure: true
same-site: lax
Posición del BFF en la topología¶
flowchart LR
B[Browser] -->|cookie session| BFF[BFF\nSpring WebFlux]
BFF -->|Authorization Code Flow| KC[Keycloak\nrealm-partners]
BFF -->|Bearer token| API1[partner-api]
BFF -->|Bearer token| API2[document-api]
KC -->|JWKS| API1
KC -->|JWKS| API2
El BFF ocupa la capa de borde del frontend. Traefik enruta el tráfico hacia él y hacia las APIs, pero no gestiona sesiones de usuario — eso es responsabilidad exclusiva del BFF.
Diferencia entre BFF y API Gateway¶
El BFF no es un proxy genérico de infraestructura:
- API Gateway (Traefik): enruta tráfico, termina TLS, aplica rate limiting y puede validar JWTs a nivel de middleware. Es agnóstico al canal de cliente.
- BFF: conoce exactamente qué necesita su frontend. Agrega datos de múltiples APIs, adapta el modelo de respuesta al modelo de vista y gestiona la sesión del usuario.
Un realm distinto en Keycloak implica, en general, un BFF distinto — el de realm-internal para empleados y el de realm-partners para socios externos.
No compartas el BFF entre frontends distintos
Si el mismo BFF sirve a la app de empleados y al portal de partners, pierdes el aislamiento de sesiones, la capacidad de customizar el flujo por canal y la posibilidad de escalar cada canal de forma independiente.
Sesión en memoria vs Redis
Para el MVP con un solo nodo Docker Compose, store-type: none (memoria JVM) es suficiente. En cuanto se añada una segunda réplica del BFF, migra a store-type: redis para compartir sesiones entre instancias sin afectar a los usuarios.