# Canje, revocación e introspección de tokens

Canjea el código por tokens, renuévalos con el refresh token, revócalos y comprueba su estado con los endpoints token, revoke e introspect.

Cuando tienes el código de autorización, el resto de la vida de los tokens pasa por tres endpoints: `POST /api/oauth/token` para obtenerlos y renovarlos, `POST /api/oauth/revoke` para anularlos y `POST /api/oauth/introspect` para saber si un access token sigue vivo. Todos admiten el cuerpo en `application/x-www-form-urlencoded` (lo habitual en OAuth) o en JSON.

## Canjear el código por tokens

**`POST /api/oauth/token`**

Canjea el código de autorización por access token, refresh token e id_token

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `grant_type` | `string` | Sí | authorization_code. |
| `code` | `string` | Sí | El código recibido en tu redirect_uri. |
| `redirect_uri` | `string` | Sí | La misma que usaste en la autorización, carácter por carácter. |
| `code_verifier` | `string` | Sí | El code_verifier original (43 a 128 caracteres). |
| `client_id` | `string` | Sí | En el cuerpo o, con client_secret_basic, en la cabecera Authorization: Basic. |
| `client_secret` | `string` | No | Solo si el cliente usa client_secret_post. Con client_secret_basic va en la cabecera. |

### curl

```bash
curl -X POST https://os.utilia.ai/api/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=authorization_code' \
  -d 'code=CODIGO_RECIBIDO' \
  -d 'redirect_uri=https://mi-app.com/callback' \
  -d 'code_verifier=TU_CODE_VERIFIER' \
  -d 'client_id=TU_CLIENT_ID'
```

### JavaScript

```javascript
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: "authorization_code",
    code: authorizationCode,
    redirect_uri: "https://mi-app.com/callback",
    code_verifier: sessionStorage.getItem("code_verifier"),
    client_id: "TU_CLIENT_ID",
  }),
});

const tokens = await response.json();
```

### Python

```python
import requests

response = requests.post(
    "https://os.utilia.ai/api/oauth/token",
    data={
        "grant_type": "authorization_code",
        "code": authorization_code,
        "redirect_uri": "https://mi-app.com/callback",
        "code_verifier": code_verifier,
        "client_id": "TU_CLIENT_ID",
    },
    timeout=30,
)
tokens = response.json()
```

### Respuesta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `access_token` | `string` | Sí | Cadena opaca de 43 caracteres. Úsala como Bearer. |
| `token_type` | `string` | Sí | Bearer. |
| `expires_in` | `number` | Sí | 3600 (1 hora). |
| `refresh_token` | `string` | Sí | Cadena opaca. Sirve para renovar sin volver a pedir consentimiento y cambia en cada uso. |
| `scope` | `string` | No | Scopes concedidos de verdad, separados por espacios. Pueden ser menos de los que pediste. |
| `id_token` | `string` | No | JWT firmado con RS256. Solo si se concedió openid. |

```json
{
  "access_token": "TU_ACCESS_TOKEN",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "TU_REFRESH_TOKEN",
  "scope": "openid crm:clients:view",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im9pZGMtcHJvZC0xIn0..."
}
```

La firma del `id_token` se verifica con las claves de `GET /api/oauth/jwks`. Los claims están en [Scopes y claims de OpenID Connect](https://os.utilia.ai/docs/developers/oauth/oidc-scopes-and-claims).

### Errores del canje

| `error` | `error_description` | Qué hacer |
| - | - | - |
| `invalid_request` | `code_verifier is required for PKCE` (o falta `code` o `redirect_uri`) | Envía todos los campos |
| `invalid_grant` | `Authorization code has expired` | El código vale 60 segundos: repite la autorización |
| `invalid_grant` | `Authorization code has already been used` | Cada código sirve una vez; los tokens emitidos con él se han revocado |
| `invalid_grant` | `redirect_uri mismatch` | Usa exactamente la dirección de la autorización |
| `invalid_grant` | `PKCE verification failed` | El `code_verifier` no corresponde al `code_challenge` |
| `invalid_client` | `Client authentication failed` | El `client_id` no existe, el cliente está desactivado o su registro venció, o falta el secreto de un cliente confidencial o no es correcto. Todos estos casos responden igual para no revelar si el cliente existe |
| `invalid_client` | `Client mismatch` | El código se emitió para otro cliente |

## Renovar con el refresh token

**`POST /api/oauth/token`**

Renueva el access token con el refresh token

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `grant_type` | `string` | Sí | refresh_token. |
| `refresh_token` | `string` | Sí | El último refresh token que recibiste. |
| `client_id` | `string` | Sí | Identificador del cliente. |
| `client_secret` | `string` | No | Obligatorio para clientes confidenciales, igual que en el canje. |

```bash
curl -X POST https://os.utilia.ai/api/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=refresh_token' \
  -d 'refresh_token=TU_REFRESH_TOKEN' \
  -d 'client_id=TU_CLIENT_ID'
```

La respuesta trae un access token nuevo de 1 hora, un **refresh token nuevo** y el `scope` vigente. No trae `id_token`. El access token anterior deja de valer en ese momento, aunque no hubiera pasado su hora. El token renovado conserva el servidor al que estaba atado: el parámetro `resource` no lo cambia.

- **El alcance solo puede encogerse.** Si la persona ha perdido permisos, el token renovado los pierde también; si los gana, el token no crece: para ampliar el alcance hay que volver a autorizar.
- **Si no queda ningún scope útil** para el servidor del token, la renovación responde `invalid_grant` (`The granted scopes are no longer available for this user`) y revoca la cadena.
- **Si la persona deja de estar activa** en UTILIA OS, la renovación falla con `invalid_grant`.
- **Si el cliente se ha desactivado**, la renovación falla con `invalid_client` (`Client authentication failed`).

> **Importante: Guarda siempre el refresh token más reciente**
>
> Cada renovación anula el refresh token que enviaste. Si vuelves a usar uno ya rotado pasado un minuto desde la rotación, UTILIA OS lo trata como un posible robo, responde `invalid_grant` (`Refresh token has been rotated`) y revoca toda la cadena de esa autorización: la persona tendrá que volver a autorizar. Dentro de ese minuto, lo entiende como otra parte de tu aplicación que renovaba a la vez y le entrega su propio par de tokens. El patrón correcto está en [Rotación del refresh token](https://os.utilia.ai/docs/developers/oauth/refresh-token-rotation).

## Revocar un token (RFC 7009)

**`POST /api/oauth/revoke`**

Revoca un access token o un refresh token. Responde siempre 200.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `token` | `string` | Sí | El access token o el refresh token que quieres anular. |
| `token_type_hint` | `string` | No | access_token o refresh_token. Con access_token solo se busca entre los access tokens. |

```bash
curl -X POST https://os.utilia.ai/api/oauth/revoke \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'token=TU_REFRESH_TOKEN' \
  -d 'token_type_hint=refresh_token'
```

Responde `200` con `{}` aunque el token no exista o ya estuviera revocado, para no revelar nada sobre él. El access token y el refresh token de una misma emisión van juntos: revocar cualquiera de los dos anula ambos. Es lo que debes hacer cuando la persona cierra sesión en tu aplicación.

## Comprobar un token (RFC 7662)

**`POST /api/oauth/introspect`**

Estado y metadatos de un access token. Exige un cliente confidencial autenticado.

Solo la puede llamar un cliente confidencial (`client_secret_basic` o `client_secret_post`) que envíe su secreto, en la cabecera `Authorization: Basic` o en el cuerpo, como pide el RFC 7662. Un cliente público (`none`) no puede usarla y recibe `401 invalid_client`, la misma respuesta que un cliente inexistente, desactivado o con el secreto incorrecto.

Además, solo puedes comprobar los tokens emitidos a tu propio cliente. El token de otro cliente responde `active: false`, igual que uno que no existe.

Si tu aplicación es pública (navegador, móvil, línea de órdenes o cliente MCP), no necesitas la introspección: cuando un access token deja de valer, la llamada que lo usa responde `401`, y lo renuevas con el refresh token.

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `token` | `string` | Sí | El access token que quieres comprobar. |
| `token_type_hint` | `string` | No | Se acepta, pero no cambia nada: solo se reconocen access tokens. Un refresh token responde active: false. |

```bash
curl -X POST https://os.utilia.ai/api/oauth/introspect \
  -u 'TU_CLIENT_ID:TU_CLIENT_SECRET' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'token=TU_ACCESS_TOKEN'
```

Si el token está vigente:

```json
{
  "active": true,
  "scope": "openid crm:clients:view",
  "client_id": "utilia_5e2a9c4d7b1f3e6a8c0d2f4b6a8e1c3d",
  "username": "usuario@empresa.com",
  "token_type": "Bearer",
  "exp": 1714933600,
  "iat": 1714930000,
  "sub": "3b1f6c2e-8a4d-4f0b-9c7e-2d5a1e6f8b90",
  "aud": "https://os.utilia.ai/api/mcp/crm",
  "iss": "https://os.utilia.ai"
}
```

`aud` es el servidor al que está atado el token. Si en la autorización no indicaste `resource` y el registro de tu cliente no tiene uno por defecto, vale `https://os.utilia.ai`: es un token de identidad, que solo sirve para `/api/oauth/userinfo`. Los tokens anteriores al 20 de octubre de 2026 emitidos sin `resource` pueden mostrar `https://os.utilia.ai/api/mcp`, la dirección retirada ese día, que ya no abre ningún servidor.

La introspección solo responde `active: true` mientras el token está vigente. Estos son los caminos por los que deja de estarlo:

**Cuándo deja de valer un access token**

1. **Vigente**. La introspección responde active: true con sus metadatos.
   - **Revocado** (estado final). Se llega desde Vigente cuando: Se revoca, se renueva o la persona retira el acceso. Al renovar, el access token anterior deja de valer en el acto.
   - **Rechazado** (estado final). Se llega desde Vigente cuando: Se desactiva el cliente al que pertenece. Deja de valer aunque no conste como revocado.
2. **Caducado** (estado final). Se llega desde Vigente cuando: Pasa 1 hora desde que se emitió. Se renueva con el refresh token.

_Fuera de Vigente, o si el token no existe, la introspección responde solo active: false, sin motivo._

Si la persona deja de estar activa en UTILIA OS, `userinfo` y los servidores MCP rechazan su token aunque siga vigente; si se da de baja, además se revoca.

## Autenticación del cliente

El método se fija al registrar el cliente (`token_endpoint_auth_method`) y vale para el canje y la renovación. La introspección solo admite los dos métodos con secreto:

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `none` | `client_id en el cuerpo` | No | Clientes públicos: navegador, móvil, línea de órdenes o clientes MCP. Sin secreto; PKCE los protege. Es el método por defecto del registro dinámico. No admite la introspección. |
| `client_secret_basic` | `Authorization: Basic base64(client_id:client_secret)` | No | Clientes con servidor propio. Es el que usa la opción «Con servidor propio» del panel. |
| `client_secret_post` | `client_id y client_secret en el cuerpo` | No | Igual que el anterior, con las credenciales en el cuerpo. |
