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 |
Sí | Tipo de grant |
refresh_token |
eyJhbGci... (opaco o JWT) |
Sí | Token emitido en el login o renovación anterior |
client_id |
partner-portal-app |
Sí | 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.