Saltar a contenido

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.