Saltar a contenido

Secuencia — Refresh Token

El refresh token permite que los clientes obtengan un nuevo access token sin requerir que el usuario vuelva a autenticarse. Es el mecanismo que mantiene las sesiones activas de forma transparente. En Keycloak 26, los refresh tokens implementan una estrategia de sliding window opcional y pueden ser revocados individualmente o en masa cuando se detecta compromiso.

Diagrama de secuencia completo

sequenceDiagram
    autonumber
    actor Usuario as Usuario
    participant App as partner-portal-app<br/>(SPA)
    participant Traefik as Traefik v3<br/>:443
    participant KC as Keycloak 26<br/>realm-partners
    participant RS as Resource Server<br/>(Spring WebFlux API)

    Note over App: Detección de access token expirado
    App->>App: Interceptor HTTP detecta:<br/>access_token exp < now() + 60s<br/>O recibe 401 del Resource Server

    alt Access token próximo a expirar (renovación proactiva)
        Note over App: Renovación antes de expirar
        App->>App: Tiene refresh_token válido en memoria<br/>exp_refresh > now()
    else Recibió 401 del Resource Server
        RS-->>App: 401 Unauthorized<br/>WWW-Authenticate: Bearer error="invalid_token"
        Note over App: Renovación reactiva
    end

    Note over App,KC: Solicitud de renovación de tokens
    App->>Traefik: POST /realms/realm-partners/protocol/openid-connect/token<br/>Content-Type: application/x-www-form-urlencoded<br/>grant_type=refresh_token<br/>&refresh_token=REFRESH_TOKEN_OPACO<br/>&client_id=partner-portal-app

    Traefik->>KC: Proxy POST (TLS terminado)

    Note over KC: Validación del refresh token
    KC->>KC: Busca refresh_token en base de datos de sesiones<br/>Verifica que no está revocado<br/>Verifica exp del refresh token<br/>Verifica que la sesión del usuario sigue activa<br/>Verifica realm y client_id

    alt Refresh token válido
        Note over KC: Emite nuevo par de tokens
        KC->>KC: Genera nuevo access_token (JWT, 5 min)<br/>Genera nuevo refresh_token (sliding window)<br/>Invalida el refresh_token anterior (rotación)

        KC-->>Traefik: 200 OK — Nuevos tokens
        Traefik-->>App: access_token (nuevo JWT)<br/>refresh_token (nuevo, invalida el anterior)<br/>expires_in: 300<br/>refresh_expires_in: 2592000

        App->>App: Reemplaza tokens en memoria<br/>Reinicia timer de renovación proactiva

        Note over App,RS: Reintento de la petición original
        App->>Traefik: GET /api/partners/data<br/>Authorization: Bearer NUEVO_ACCESS_TOKEN
        Traefik->>RS: Proxy con nuevo token
        RS-->>App: 200 OK — Datos del recurso

    else Refresh token expirado o revocado
        KC-->>Traefik: 400 Bad Request<br/>{"error":"invalid_grant","error_description":"Token is not active"}
        Traefik-->>App: 400 Bad Request

        App->>App: Limpia tokens del storage<br/>Estado de autenticación = logged_out
        App->>Usuario: Redirige a pantalla de login<br/>(Authorization Code + PKCE flow)
    end

Sliding Window y tiempo de vida

sequenceDiagram
    autonumber
    participant App as Aplicación
    participant KC as Keycloak

    Note over App,KC: Configuración: access_token 5 min, refresh_token 30 días

    App->>KC: POST token (grant=refresh_token) en T+4min
    KC-->>App: Nuevo access_token (5 min)<br/>Nuevo refresh_token (30 días desde T+4min)

    Note over App: Ventana deslizante: el refresh_token<br/>se renueva cada vez que se usa

    App->>KC: POST token (grant=refresh_token) en T+1 día
    KC-->>App: access_token nuevo<br/>refresh_token nuevo (30 días desde T+1día)

    Note over KC: Si el usuario NO usa la app por 30 días:<br/>el refresh_token expira y debe hacer login nuevamente

Parámetros del Refresh Token Request

Parámetro Valor Obligatorio Descripción
grant_type refresh_token Tipo de grant
refresh_token eyJhbGci... (opaco o JWT) Token emitido en el login o renovación anterior
client_id partner-portal-app ID del cliente que solicitó el token original
scope openid profile email No Si se omite, mantiene el scope original

Implementación del interceptor en Spring WebFlux

// Interceptor HTTP reactivo con renovación automática de tokens
@Component
public class OAuth2TokenRefreshFilter implements ExchangeFilterFunction {

    private final TokenStore tokenStore;
    private final KeycloakTokenClient keycloakClient;
    private final Scheduler scheduler;

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        return tokenStore.getAccessToken()
            .flatMap(token -> {
                // Renovación proactiva: si expira en menos de 60 segundos
                if (token.expiresInSeconds() < 60) {
                    return refreshTokenAndRetry(request, next);
                }
                return next.exchange(withBearerToken(request, token.value()));
            })
            .flatMap(response -> {
                // Renovación reactiva: si recibimos 401
                if (response.statusCode() == HttpStatus.UNAUTHORIZED) {
                    return response.releaseBody()
                        .then(refreshTokenAndRetry(request, next));
                }
                return Mono.just(response);
            });
    }

    private Mono<ClientResponse> refreshTokenAndRetry(ClientRequest original, ExchangeFunction next) {
        return tokenStore.getRefreshToken()
            .flatMap(keycloakClient::refreshAccessToken)
            .flatMap(newTokens -> {
                tokenStore.save(newTokens);
                return next.exchange(withBearerToken(original, newTokens.accessToken()));
            })
            .onErrorResume(OAuth2TokenExpiredException.class, e -> {
                tokenStore.clear();
                return Mono.error(new SessionExpiredException("Sesión expirada. Por favor inicia sesión nuevamente."));
            });
    }

    private ClientRequest withBearerToken(ClientRequest request, String token) {
        return ClientRequest.from(request)
            .headers(headers -> headers.setBearerAuth(token))
            .build();
    }
}

Seguridad del refresh token

Almacenamiento seguro

// SPA: Almacenamiento del refresh token
// CORRECTO: en memoria (no persiste entre pestañas, pero es más seguro)
class TokenManager {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
  private expiresAt: number = 0;

  setTokens(response: TokenResponse): void {
    this.accessToken = response.access_token;
    this.refreshToken = response.refresh_token;
    this.expiresAt = Date.now() + response.expires_in * 1000;
  }

  isAccessTokenValid(): boolean {
    // Margen de 60 segundos para renovación proactiva
    return this.accessToken !== null && Date.now() < this.expiresAt - 60_000;
  }

  clear(): void {
    this.accessToken = null;
    this.refreshToken = null;
    this.expiresAt = 0;
  }
}

// INCORRECTO: localStorage expone el refresh_token a XSS
// localStorage.setItem('refresh_token', token.refresh_token); // NO HACER

Rotación de refresh tokens

# Configuración en Keycloak (realm-partners) — keycloak.conf o Admin API
# Keycloak 26 habilita rotación por defecto en clientes públicos
refresh-token-rotation: true   # Cada uso invalida el anterior

# Configuración del realm en Keycloak Admin Console:
# Realm Settings → Sessions → SSO Session Idle: 30 minutes
# Realm Settings → Sessions → SSO Session Max: 8 hours
# Client Settings → Advanced → Refresh Token Max Reuse: 0 (rotación estricta)

Revocación de refresh tokens

# Revocar un refresh token específico (logout del dispositivo)
curl -X POST "https://kc.empresa.com/realms/realm-partners/protocol/openid-connect/revoke" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "partner-portal-app:" \
  -d "token=REFRESH_TOKEN" \
  -d "token_type_hint=refresh_token"

# Logout completo: invalida todos los tokens de la sesión
curl -X POST "https://kc.empresa.com/realms/realm-partners/protocol/openid-connect/logout" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=partner-portal-app" \
  -d "refresh_token=REFRESH_TOKEN"

Condiciones que invalidan el refresh token

Condición Comportamiento Acción del cliente
exp del refresh token alcanzado Keycloak retorna invalid_grant Redirigir a login
Logout del usuario desde otra sesión SSO logout invalida todos los refresh tokens Detectar 400, redirigir a login
Revocación manual por administrador Token marcado como revocado en BD Detectar 400, redirigir a login
Rotación: reutilización de token ya usado Keycloak detecta reuse attack, revoca sesión completa Redirigir a login, alertar al equipo de seguridad
Cambio de contraseña del usuario Keycloak invalida todas las sesiones activas Detectar 400, redirigir a login
Realm deshabilitado o cliente deshabilitado client_disabled o realm_disabled Mostrar error de acceso

Detección de reuse de refresh token

Keycloak 26 con rotación habilitada detecta automáticamente cuando un refresh token ya usado se vuelve a presentar. Esto indica posible robo del token. En ese caso, Keycloak revoca toda la sesión (no solo el token presentado) como medida de contención. Implementa logs de alerta cuando el usuario sea redirigido a login de forma inesperada.

Refresh token en aplicaciones móviles

En apps móviles nativas, el refresh token puede almacenarse en el Keychain (iOS) o Keystore (Android). Nunca en preferencias compartidas o archivos planos. Considerar el uso de tokens de larga duración solo si el dispositivo tiene biometría configurada.

Monitoreo de renovaciones fallidas

Configura alertas cuando la tasa de renovaciones fallidas supere un umbral (p.ej. >5% de las renovaciones en 5 minutos). Puede indicar ataques de fuerza bruta, tokens comprometidos o un problema de configuración en el tiempo de vida de los refresh tokens.