# Rotación del refresh token

Cada renovación entrega un refresh token nuevo y anula el anterior: guarda siempre el último y qué pasa si reutilizas uno viejo o renuevas a la vez.

UTILIA OS rota el refresh token en cada renovación, como recomienda OAuth 2.1, y detecta cuándo alguien reutiliza uno ya rotado. Para tu código, la consecuencia es una sola regla: después de cada renovación, guarda el refresh token que acabas de recibir y olvida el anterior. Esta página explica por qué y cómo hacerlo bien, también cuando varias partes de tu aplicación renuevan a la vez.

## Qué cambia en cada renovación

Cuando llamas a `POST /api/oauth/token` con `grant_type=refresh_token`, recibes un access token nuevo, de 1 hora, y un refresh token nuevo que sustituye al que enviaste. Así vive cada refresh token:

**Vida de un refresh token**

1. **Vigente**. El último que recibiste. Es el que debe servir para renovar.
   - **Caducado** (estado final). Se llega desde Vigente cuando: Pasan 90 días desde la autorización. La renovación responde invalid_grant. Hay que volver a autorizar.
   - **Revocado** (estado final). Se llega desde Vigente cuando: POST /api/oauth/revoke o la persona retira el acceso. Deja de valer junto con su access token.
2. **Anulado por rotación**. Se llega desde Vigente cuando: Se usa para renovar y llega uno nuevo. Ya no sirve: guarda el nuevo. Durante un minuto, otra renovación con él recibe su propio par de tokens en la misma cadena.
   - **Cadena revocada** (estado final). Se llega desde Anulado por rotación cuando: Se reutiliza pasado un minuto: invalid_grant. Caen todos los tokens de esa autorización. Hay que volver a autorizar.

_Solo el último refresh token de la cadena está vigente. Reutilizar uno anulado corta la cadena entera, salvo en el minuto siguiente a su rotación._

El plazo de 90 días cuenta desde la autorización y no se alarga al rotar: si la persona autorizó el 1 de enero, la cadena caduca a principios de abril aunque hayas renovado cien veces. Después, tiene que volver a autorizar.

## Por qué se rota

Si alguien roba un refresh token, sin rotación podría renovar indefinidamente sin que nadie lo notara. Con rotación, el refresh token robado y el legítimo acaban chocando: el segundo que se use ya está anulado, el servidor lo detecta y corta la cadena entera. El atacante se queda sin acceso y la persona solo tiene que volver a autorizar.

El único margen es de un minuto desde la rotación, para no confundir con un robo a dos partes de tu propia aplicación que renuevan a la vez (lo explica [Renovaciones simultáneas](#renovaciones-simultáneas)).

## Qué pasa si reutilizas uno anulado

Si llega un refresh token que se rotó hace más de un minuto, o cuya cadena ya no está viva, UTILIA OS:

- Responde `400` con `error: "invalid_grant"` y `error_description: "Refresh token has been rotated"`.
- Revoca todos los tokens de esa cadena, es decir, los que nacieron de la misma autorización, incluido el access token que tuvieras en uso. Las demás autorizaciones de la misma persona con tu aplicación (otro servidor MCP, otro aparato) no se tocan.

A partir de ahí, la única salida es repetir el flujo de autorización.

> **Importante:** Basta un descuido para que la persona tenga que volver a autorizar: guardar el refresh token una sola vez y no actualizarlo, tenerlo copiado en dos sitios o, pasado el minuto, reintentar con el viejo después de un corte de red en el que la renovación sí llegó a hacerse.

## Renovaciones simultáneas

Si dos procesos de tu aplicación (dos pestañas, dos trabajadores) renuevan con el mismo refresh token, el resultado depende de cuándo llega el segundo:

**Dos renovaciones con el mismo refresh token**

1. **0 s**: El primero renueva. Recibe un par nuevo; el refresh que envió queda anulado.
2. **Antes de 60 s**: El segundo, con el mismo. Recibe su propio par en la misma cadena. No se revoca nada.
3. **Después de 60 s**: Alguien lo reutiliza. invalid_grant con «Refresh token has been rotated» y la cadena entera revocada.

_El minuto cuenta desde la primera rotación y no se alarga con cada renovación hermana._

Si las dos peticiones se cruzan en el servidor en el mismo instante, UTILIA OS la reintenta por dentro y el segundo proceso recibe también su propio par: tu código no ve ningún error.

El margen salva las renovaciones simultáneas, no el desorden: un proceso que se queda con el refresh viejo y renueva con él más tarde corta la cadena de todos. Por eso:

- **Serializa las renovaciones.** En el navegador, usa `navigator.locks.request` o comparte una única promesa de renovación entre pestañas; en el servidor, un cerrojo por persona y cliente. Los SDK oficiales ya comparten una sola renovación cuando varias peticiones la necesitan a la vez.
- **Lee el almacenamiento justo antes de renovar.** Si otro proceso ya renovó mientras esperabas el cerrojo, su access token sigue vigente: úsalo y no renueves.

## Cómo adaptar tu código

### 1. Guarda los dos tokens tras cada renovación

#### JavaScript

```javascript
async function refreshTokens(storedRefreshToken, clientId) {
  const response = await fetch("https://os.utilia.ai/api/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: storedRefreshToken,
      client_id: clientId,
    }),
  });

  const body = await response.json();
  if (!response.ok) {
    throw Object.assign(new Error(body.error_description), { code: body.error });
  }

  // Sustituye los DOS tokens en tu almacenamiento persistente.
  await tokenStore.save({
    accessToken: body.access_token,
    refreshToken: body.refresh_token,
    expiresAt: Date.now() + body.expires_in * 1000,
  });

  return body;
}
```

#### Python

```python
import time

import requests


def refresh_tokens(stored_refresh_token: str, client_id: str) -> dict:
    response = requests.post(
        "https://os.utilia.ai/api/oauth/token",
        data={
            "grant_type": "refresh_token",
            "refresh_token": stored_refresh_token,
            "client_id": client_id,
        },
        timeout=30,
    )
    response.raise_for_status()
    tokens = response.json()

    # Sustituye los DOS tokens en tu almacenamiento persistente.
    token_store.save(
        access_token=tokens["access_token"],
        refresh_token=tokens["refresh_token"],
        expires_at=time.time() + tokens["expires_in"],
    )
    return tokens
```

`tokenStore` y `token_store` representan tu propio almacenamiento (base de datos, sesión, archivo).

### 2. Renueva una sola vez y trata `invalid_grant`

#### JavaScript

```javascript
async function getFreshAccessToken(clientId) {
  // withRefreshLock: tu cerrojo por persona y cliente (navigator.locks en el
  // navegador, un cerrojo en tu base de datos o en Redis en el servidor).
  return withRefreshLock(async () => {
    const stored = await tokenStore.read();
    // Otro proceso pudo renovar mientras esperabas el cerrojo.
    if (stored.expiresAt - Date.now() > 60_000) return stored.accessToken;

    try {
      const tokens = await refreshTokens(stored.refreshToken, clientId);
      return tokens.access_token;
    } catch (err) {
      if (err.code !== "invalid_grant") throw err;
      // Reutilización fuera de plazo, caducidad, persona inactiva o sin alcance:
      // volver a autorizar.
      await tokenStore.clear();
      throw new Error("REAUTHORIZE");
    }
  });
}
```

#### Python

```python
import threading

_refresh_lock = threading.Lock()  # en varios procesos, un cerrojo compartido


def get_fresh_access_token(client_id: str) -> str:
    with _refresh_lock:
        stored = token_store.read()
        # Otro hilo pudo renovar mientras esperabas el cerrojo.
        if stored["expires_at"] - time.time() > 60:
            return stored["access_token"]
        try:
            return refresh_tokens(stored["refresh_token"], client_id)["access_token"]
        except requests.HTTPError as err:
            body = err.response.json()
            if body.get("error") != "invalid_grant":
                raise
            # Reutilización fuera de plazo, caducidad, persona inactiva o sin
            # alcance: volver a autorizar.
            token_store.clear()
            raise RuntimeError("REAUTHORIZE") from err
```

`withRefreshLock` y `_refresh_lock` representan tu cerrojo; `tokenStore` y `token_store`, tu almacenamiento, los mismos del paso anterior.

## Preguntas frecuentes

**¿Puedo seguir usando un refresh token antiguo si lo guardé en varios sitios?** No. Solo vale el último. Uno anterior solo sirve en el minuto siguiente a su rotación; después, revoca la cadena.

**¿Cuánto vive la cadena?** 90 días desde la autorización. Rotar no alarga ese plazo.

**¿Y si mi aplicación está mucho tiempo sin conexión?** Si al volver el refresh token ha caducado, la renovación responde `invalid_grant` (`Refresh token has expired`). Detéctalo y lanza de nuevo la autorización.

**¿Al renovar puedo cambiar de servidor MCP?** No. El token renovado sigue atado al mismo servidor. Para otro servidor, pide otra autorización con su `resource`.

## Relación con otros mecanismos

| Mecanismo | Qué hace | Cuándo actúa |
| - | - | - |
| Rotación | Entrega un refresh token nuevo y anula el anterior | En cada renovación correcta |
| Renovación simultánea | Entrega otro par en la misma cadena, sin revocar nada | Al llegar un refresh token anulado hace menos de un minuto, con la cadena viva |
| Detección de reutilización | Revoca toda la cadena de esa autorización | Al llegar un refresh token anulado hace más de un minuto |
| Revocación | Anula el access token y el refresh token de una emisión | Al llamar a `POST /api/oauth/revoke` |
| Retirada desde UTILIA OS | Revoca todos los tokens de tu aplicación para esa persona | Cuando la persona la quita en Mi perfil › IA y aplicaciones |
| Persona inactiva | Rechaza sus tokens aunque no hayan caducado | Mientras su cuenta no esté activa |
| Cliente desactivado | Invalida todos sus tokens y no deja renovar | Cuando se desactiva OAuth en la aplicación |
