# Problemas frecuentes con OAuth

Cómo resolver los errores más habituales al integrar el inicio de sesión con UTILIA: direcciones de vuelta, PKCE, invalid_grant, CORS, permisos y límites de uso.

Esta página recoge los errores que más aparecen al integrar OAuth con UTILIA OS, con su causa y su solución. Busca el código de error (`invalid_grant`, `invalid_client`...) o el síntoma. Los errores de OAuth llegan siempre con `error` y `error_description`: el segundo dice la causa exacta, salvo cuando falla la autenticación del cliente al pedir o comprobar tokens (`invalid_client`), que por seguridad no la revela.

**¿Dónde falla tu integración?**

¿En qué momento aparece el error?

- Al pedir la autorización: ¿Qué ves?
  - La página de error: ¿Qué error muestra la página de error de UTILIA OS?
    - `invalid_redirect_uri`: [**La dirección de vuelta no coincide**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#la-dirección-de-vuelta-no-coincide). Se compara carácter por carácter con las registradas para el cliente.
    - `invalid_client`: [**Cliente inexistente o desactivado**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#invalid_client). Con Client not found o Client is inactive.
  - `invalid_request`: [**Revisa el PKCE**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#errores-de-pkce). Si el error_description habla de code_challenge: falta, no usa S256 o no tiene el formato.
  - `login_required`: [**prompt=none no renueva en silencio**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#promptnone-siempre-devuelve-login_required). Sin sesión de UTILIA OS en el navegador, responde siempre así. Usa el refresh token.
  - Un aviso al consentir: [**La persona no puede autorizar**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#la-persona-no-puede-autorizar). Le faltan permisos, su plan no incluye esos scopes o ha desactivado todos los del servidor MCP.
- Al pedir o renovar tokens: ¿Qué responde POST /api/oauth/token?
  - `invalid_grant`: ¿Canjeabas un código o renovabas?
    - Canjeaba un código: [**Código, PKCE o dirección de vuelta**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#invalid_grant). El código vale 60 segundos y una sola vez. El error_description dice qué comprobación falla.
    - Renovaba: [**Refresh token anulado o caducado**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#mi-refresh-token-ha-dejado-de-funcionar). Con has been rotated, usaste uno ya anulado; con has expired, han pasado los 90 días desde la autorización.
  - `invalid_client`: [**Revisa las credenciales del cliente**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#invalid_client). El client_id no existe o está desactivado, falta la clave secreta o no es correcta.
  - `429`: [**Espera lo que diga Retry-After**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#límites-de-uso). Este endpoint admite 60 peticiones por minuto y dirección IP.
  - Llega sin id_token: [**Pide el scope openid**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#no-llega-el-id_token). Solo llega en el canje del código y si pediste openid; nunca al renovar.
- Al llamar con el token: ¿Qué responde la llamada?
  - `401`: ¿Ha pasado una hora desde que recibiste el access token?
    - Sí: [**Ha caducado**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#el-access-token-deja-de-valer-a-la-hora). Dura 1 hora. Renueva con el refresh token.
    - No: ¿Qué responde al renovar?
      - `invalid_client`: [**El cliente está desactivado**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#invalid_client). Desactivar el cliente deja sin validez todos sus tokens, aunque no hayan caducado.
      - `invalid_grant`: [**La persona ya no tiene acceso**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#una-persona-desactivada). Con El usuario ya no puede renovar este acceso, su cuenta ya no está activa. Pídele de nuevo la autorización.
  - Lo bloquea el navegador: [**Autoriza el dominio de tu web**](https://os.utilia.ai/docs/developers/oauth/troubleshooting#cors-al-llamar-desde-el-navegador) (`403 CORS_ORIGIN_NOT_ALLOWED`). Añádelo en los dominios autorizados de tu aplicación externa.
- Al cerrar la sesión: [**Revisa el id_token_hint**](https://os.utilia.ai/docs/developers/oauth/logout#errores) (`invalid_request`). Su firma se verifica en RS256; también cuentan iss, el cliente activo y la dirección de vuelta registrada.

_Cada resultado lleva al apartado de esta página que lo resuelve. El error_description concreta la causa, salvo en invalid_client._

## La dirección de vuelta no coincide

Síntoma: al iniciar la autorización, la persona ve la página de error de UTILIA OS con `invalid_redirect_uri`; o, al canjear el código, recibes `invalid_grant` con `redirect_uri mismatch`.

La `redirect_uri` se compara **carácter por carácter** con las registradas para el cliente y, en el canje, con la que usaste en la autorización.

| Registrada | Enviada | Resultado |
| - | - | - |
| `https://miapp.com/callback` | `http://miapp.com/callback` | Falla: cambia el protocolo |
| `http://localhost:3000/callback` | `http://localhost:3001/callback` | Falla: cambia el puerto |
| `https://miapp.com/callback` | `https://www.miapp.com/callback` | Falla: cambia el dominio |
| `http://localhost:3000/callback` | `http://localhost:3000/callback/` | Falla: sobra la barra final |
| `http://localhost:3000/callback` | `http://localhost:3000/callback` | Correcto |

En producción las direcciones tienen que usar `https`; solo `localhost`, `127.0.0.1` y `[::1]` admiten `http`. Para añadir una, edita las direcciones de vuelta en [Activar OAuth en tu aplicación](https://os.utilia.ai/docs/developers/oauth/apps-panel) o registra de nuevo el cliente.

## `invalid_grant`

El `error_description` te dice la causa:

| `error_description` | Causa y solución |
| - | - |
| `Authorization code has expired` | El código vale 60 segundos. Repite la autorización y canjéalo enseguida |
| `Authorization code has already been used` | Cada código sirve una vez y, si se reutiliza, se revocan los tokens emitidos con él. Revisa que tu aplicación no canjee dos veces, por ejemplo al recargar la página de vuelta |
| `PKCE verification failed` | El `code_verifier` no corresponde al `code_challenge`. Comprueba que usas el par de la misma autorización |
| `Invalid code_verifier format` | El `code_verifier` no tiene entre 43 y 128 caracteres del conjunto `[A-Za-z0-9-._~]` |
| `redirect_uri mismatch` | Ver el apartado anterior |
| `Refresh token has been rotated` | Has usado un refresh token ya anulado. Ver [Mi refresh token ha dejado de funcionar](#mi-refresh-token-ha-dejado-de-funcionar) |
| `Refresh token has expired` | Han pasado los 90 días desde la autorización. La persona tiene que volver a autorizar |
| `The granted scopes are no longer available for this user` | La persona ha perdido los permisos de todo lo que concedía el token. Vuelve a pedir la autorización |
| `El usuario ya no puede renovar este acceso` | La cuenta de la persona ya no está activa |

## Errores de PKCE

- **Base64 en lugar de Base64URL**: cambia `+` por `-` y `/` por `_`, y quita los `=` del final.
- **Método `plain`**: solo se admite `S256`.
- **`code_challenge` olvidado**: la autorización sin él se rechaza. En Authlib, declara `code_challenge_method: "S256"` en `client_kwargs`.
- **`code_verifier` perdido entre la redirección y la vuelta**: guárdalo en la sesión de tu servidor o en `sessionStorage`, no en una variable en memoria que se pierde al recargar la página.

Implementación de referencia:

### JavaScript

```javascript
import { randomBytes, createHash } from "node:crypto";

const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
```

### Python

```python
import base64
import hashlib
import secrets

code_verifier = secrets.token_urlsafe(32)
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
```

## Mi refresh token ha dejado de funcionar

Síntoma: `invalid_grant` con `Refresh token has been rotated`.

Cada renovación entrega un refresh token nuevo y anula el anterior. Si se vuelve a usar uno anulado, UTILIA OS lo trata como un posible robo y revoca toda la cadena de esa autorización. Las causas habituales:

- Tu aplicación guarda el refresh token una vez y no lo actualiza tras renovar.
- Dos procesos o pestañas renuevan con el mismo refresh token, uno detrás del otro.
- Una renovación llegó a hacerse, pero la respuesta se perdió por un corte de red, y tu aplicación reintenta con el refresh token viejo.

Solución: guarda siempre el último refresh token, serializa las renovaciones y, cuando recibas este error, borra lo guardado y vuelve a pedir la autorización. El patrón completo está en [Rotación del refresh token](https://os.utilia.ai/docs/developers/oauth/refresh-token-rotation).

## El access token deja de valer a la hora

Es lo esperado: el access token dura 1 hora y el refresh token 90 días desde la autorización. Renueva con el refresh token cuando el access token esté a punto de caducar o cuando recibas un `401`; si la renovación falla con `invalid_grant`, vuelve a pedir la autorización. Los SDK oficiales lo hacen solos.

## `invalid_client`

En `POST /api/oauth/token` y `POST /api/oauth/introspect`, todos los fallos de autenticación del cliente responden lo mismo, `401` con `Client authentication failed`, para no revelar si un `client_id` existe. También la petición que no lleva `client_id`. La respuesta trae siempre el mismo `error_uri`, la dirección de este apartado en la documentación pública de UTILIA OS:

```json
{
  "error": "invalid_client",
  "error_description": "Client authentication failed",
  "error_uri": "<dirección de este apartado>"
}
```

Si tu cliente se autenticó con la cabecera `Authorization: Basic`, el `401` lleva además `WWW-Authenticate: Basic realm="UTILIA OS"`. Si mandaste las credenciales en el cuerpo, no la lleva.

Si lo recibes, comprueba:

- Que la petición lleve el `client_id`, completo y de esta aplicación.
- Que el cliente siga activo y su registro no haya vencido. Desactivar OAuth en el panel lo desactiva: actívalo de nuevo y usa las credenciales nuevas.
- Que envíes la clave secreta si el cliente es de tipo **Con servidor propio**, y que sea la correcta.
- En la introspección, que el cliente sea confidencial: un cliente público no puede usarla.

| `error_description` | Causa y solución |
| - | - |
| `Client authentication failed` | Cualquiera de los casos de la lista anterior, también la falta de `client_id` |
| `Client mismatch` | El código o el refresh token se emitieron para otro cliente |

Si el cliente es el de una aplicación externa de tu organización, un rechazo en la introspección aparece también en **Actividad › Errores**, en la ficha de la aplicación, como mucho una vez cada 24 horas. El texto es siempre el mismo y no dice cuál de las causas ha sido.

Al iniciar la autorización, la página de error de UTILIA OS sí distingue `Client not found` (el `client_id` no existe) de `Client is inactive` (el cliente está desactivado), y no te devuelve a tu aplicación.

Desactivar el cliente deja sin validez todos sus tokens al momento, aunque no hayan caducado: las llamadas con ellos responden `401`, la introspección los da por inactivos (`active: false`) y las renovaciones fallan con `invalid_client` (`Client authentication failed`).

## CORS al llamar desde el navegador

Síntoma: el navegador bloquea la llamada a `https://os.utilia.ai/api/...` desde tu web, o recibes `403` con `CORS_ORIGIN_NOT_ALLOWED`.

Añade el dominio de tu web (por ejemplo `https://miapp.com`) en la ficha de tu aplicación externa, en **Configuración › Ajustes**, pestaña **Avanzado**, bloque **Dominios autorizados**. Admite comodines como `https://*.tu-dominio.com`. El cambio tarda hasta un minuto en aplicarse. Las llamadas desde un servidor (Node.js, Python...) no pasan por CORS.

## La persona no puede autorizar

- **«Acceso no autorizado»**: a la persona no le queda ningún permiso que conceder entre los que pides. Pide menos scopes o pide al administrador de su organización que le dé los permisos de UTILIA OS que correspondan.
- **«Tu organización no tiene contratado el aplicativo necesario para conceder este acceso»**: el plan de su organización no incluye el aplicativo de esos scopes. Solo se resuelve cambiando el plan.
- **«Con esta selección, tu aplicación no podría usar...»**: la persona ha desactivado todos los permisos del servidor MCP que conectas. Tiene que dejar activado al menos uno.

Para ver qué puede conceder una persona, la pantalla de consentimiento usa `GET /api/oauth/user/scopes` con su sesión. Los conjuntos y scopes están en [Scopes y recursos](https://os.utilia.ai/docs/developers/oauth/scopes-and-resources).

## Una persona desactivada

Si la cuenta de la persona deja de estar activa en UTILIA OS, sus tokens se rechazan aunque no hayan caducado y las renovaciones fallan; si se da de baja, además se revocan. Cuando vuelva a tener acceso, pídele de nuevo la autorización.

## No llega el `id_token`

- **No pediste `openid`**: inclúyelo en `scope`, por ejemplo `openid profile email`.
- **Estás renovando**: el `id_token` solo llega en el canje del código, no en las renovaciones. Guárdalo la primera vez o pide los datos a `GET /api/oauth/userinfo`.

## `prompt=none` siempre devuelve `login_required`

El navegador no envía la sesión de UTILIA OS al servidor de autorización, así que una redirección con `prompt=none` responde siempre `login_required`. No sirve para renovar en silencio: usa el refresh token.

## Límites de uso

Si superas un límite, recibes `429 Too Many Requests` con la cabecera `Retry-After`, que dice cuántos segundos esperar antes de reintentar.

| Endpoint | Límite por dirección IP |
| - | - |
| `GET /.well-known/*` | 600 por minuto |
| `POST /api/oauth/register` | 30 por hora |
| `GET /api/oauth/authorize-request/:id` | 60 por minuto |
| `POST /api/oauth/token` | 60 por minuto |
| `POST /api/oauth/revoke` | 60 por minuto |
| `POST /api/oauth/introspect` | 120 por minuto |
| `GET /api/oauth/scopes` | 60 por minuto |
| `GET /api/oauth/clients/:clientId` | 30 por minuto |
| `GET` y `POST /api/oauth/logout` | 30 por minuto |

Los documentos de descubrimiento cambian muy poco: guárdalos en caché en lugar de pedirlos en cada inicio de sesión.
