# OAuth con el SDK

Usa el SDK en nombre de una persona con OAuth 2.1 y PKCE, guarda sus tokens donde quieras y deja que el SDK los renueve.

Con OAuth tu aplicación actúa en nombre de una persona de UTILIA OS y solo con los permisos que ella acepta, en lugar de usar la clave de la aplicación. Es la vía para un botón «Iniciar sesión con UTILIA» o para una herramienta que trabaja con los datos de quien la usa. El SDK se ocupa de PKCE, del intercambio del código por tokens y de renovarlos antes de que caduquen.

Si lo que quieres es solo el botón de inicio de sesión, tienes la guía paso a paso en [Iniciar sesión con UTILIA usando el SDK JavaScript](https://os.utilia.ai/docs/developers/oauth/sign-in-sdk-js) y en su equivalente [para Python](https://os.utilia.ai/docs/developers/oauth/sign-in-sdk-python). Los conceptos (scopes, consentimiento, rotación de tokens) están en [OAuth: visión general](https://os.utilia.ai/docs/developers/oauth/overview).

## Configurar el cliente con OAuth

En lugar de la clave, pasa la configuración `oauth`. Los campos obligatorios son el identificador de cliente y la dirección de vuelta registrada en tu aplicación.

| JavaScript | Python | Obligatorio | Qué es |
| - | - | - | - |
| `clientId` | `client_id` | Sí | Identificador de tu aplicación OAuth. |
| `redirectUri` | `redirect_uri` | Sí | Dirección de vuelta, exactamente la registrada. |
| `clientSecret` | `client_secret` | No | Secreto, solo para aplicaciones confidenciales (de servidor). |
| `scopes` | `scopes` | No | Scopes que pides. Por defecto `openid`, `profile` y `email`. |
| `tokenStorage` | `token_storage` | No | Dónde se guardan los tokens. Por defecto, en memoria. |

### JavaScript

```typescript
import { UtiliaSDK, FileTokenStorage } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  oauth: {
    clientId: process.env.UTILIA_CLIENT_ID!,
    clientSecret: process.env.UTILIA_CLIENT_SECRET, // solo aplicaciones confidenciales
    redirectUri: 'http://localhost:8080/callback',
    scopes: ['openid', 'profile', 'email'],
    tokenStorage: new FileTokenStorage('.utilia-tokens.json'),
  },
});
```

### Python

```python
import os

from utilia_sdk import FileTokenStorage, UtiliaSDK

sdk = UtiliaSDK(
    base_url="https://os.utilia.ai/api",
    oauth={
        "client_id": os.environ["UTILIA_CLIENT_ID"],
        "client_secret": os.environ.get("UTILIA_CLIENT_SECRET"),  # solo confidenciales
        "redirect_uri": "http://localhost:8080/callback",
        "scopes": ["openid", "profile", "email"],
        "token_storage": FileTokenStorage(".utilia-tokens.json"),
    },
)
```

PKCE va incluido: el SDK genera el verificador, calcula el desafío con SHA-256 y lo envía en cada paso. No tienes que programarlo.

## El flujo de autorización

El recorrido completo, con lo que hace el SDK en cada paso:

**Autorización con PKCE desde el SDK**

Participantes: Tu aplicación, Navegador y UTILIA OS.

1. Tu aplicación (por su cuenta): getAuthorizationUrl() genera el verificador, el desafío S256 y el state, y guarda el estado pendiente en el almacenamiento de tokens.
2. Tu aplicación → Navegador: Redirige a la dirección de autorización. Lleva client_id, redirect_uri, scope, state y code_challenge.
3. Navegador → UTILIA OS: `GET /api/oauth/authorize`
4. UTILIA OS (por su cuenta): La persona inicia sesión y acepta los permisos en la pantalla de consentimiento.
5. UTILIA OS → Navegador (respuesta): Vuelve a redirectUri con code y state
6. Navegador → Tu aplicación: Llega a tu ruta de vuelta. Pasas el code y el state a handleCallback(); el SDK comprueba que el state coincide.
7. Tu aplicación → UTILIA OS: `POST /api/oauth/token`. Con el code y el code_verifier guardado.
8. UTILIA OS → Tu aplicación (respuesta): access_token y refresh_token. El SDK los guarda; desde aquí cada petición lleva Authorization: Bearer y el token se renueva solo.

_getAuthorizationUrl y handleCallback (en Python, get_authorization_url y handle_callback) se ocupan de PKCE y del state._

### JavaScript

```typescript
// Antes de redirigir (con tema opcional para el consentimiento)
const authUrl = await sdk.oauth.getAuthorizationUrl({ theme: 'dark' });

// En tu ruta de vuelta
const code = url.searchParams.get('code')!;
const state = url.searchParams.get('state') ?? undefined;
await sdk.oauth.handleCallback(code, undefined, state);

const user = await sdk.oauth.getUserInfo();
console.log(`Hola, ${user.name}`);
```

### Python

```python
# Antes de redirigir (con tema opcional para el consentimiento)
auth_url = await sdk.oauth.get_authorization_url(theme="dark")

# En tu ruta de vuelta
await sdk.oauth.handle_callback(code, callback_state=state)

user = await sdk.oauth.get_user_info()
print(f"Hola, {user.name}")
```

La comprobación del `state` solo se hace si se lo pasas a `handleCallback()`: pásalo siempre. Las dos funciones de la dirección aceptan además `scopes`, `state`, `prompt`, `loginHint` (`login_hint`), `nonce`, `resource` y `maxAge` (`max_age`), y `theme` con `light` o `dark` para fijar el tema de la pantalla de consentimiento.

Si prefieres gestionar tú el verificador y el `state`, `getLoginUrl()` (`get_login_url()`) devuelve la dirección junto con los dos valores, y después se los pasas a `handleCallback()`.

> **Atención: En el navegador, el almacenamiento en memoria no sobrevive a la redirección**
>
> El almacenamiento por defecto guarda el estado PKCE en memoria. Si tu aplicación web cambia de página para ir a autorizar, al volver ese estado ya no existe y `handleCallback()` falla con «No se encontró el estado PKCE pendiente». Usa un `tokenStorage` propio que implemente también `getPendingState`, `setPendingState` y `clearPendingState` (por ejemplo, sobre `sessionStorage`), o la ventana emergente que se explica más abajo.

## Dónde se guardan los tokens

| Almacenamiento | Dónde guarda | Para qué |
| - | - | - |
| `MemoryTokenStorage` | En memoria del proceso | Por defecto. Se pierde al reiniciar. |
| `FileTokenStorage(ruta)` | Un fichero JSON (y otro aparte para el estado PKCE), con permisos `600` | Scripts y herramientas de terminal que deben recordar la sesión entre ejecuciones. |
| Uno propio | Donde tú decidas (base de datos, Redis…) | Servidores y aplicaciones web. |

El fichero de tokens contiene credenciales: añádelo a `.gitignore`.

Para uno propio, en JavaScript implementa la interfaz `TokenStorage` (`getTokens`, `setTokens`, `clearTokens` y, opcionalmente, los tres del estado PKCE). En Python hereda de la clase abstracta `TokenStorage` y define `get_tokens`, `set_tokens` y `clear_tokens` (asíncronos); para `UtiliaSDKSync`, de `SyncTokenStorage`, con `get_tokens_sync`, `set_tokens_sync` y `clear_tokens_sync`. En Python, `OAuthTokens` es un `dataclass`: se serializa con `dataclasses.asdict()`.

### JavaScript

```typescript
import type { OAuthTokens, TokenStorage } from '@utilia-os/sdk-js';

class RedisTokenStorage implements TokenStorage {
  constructor(private readonly key: string) {}

  async getTokens(): Promise<OAuthTokens | null> {
    const data = await redis.get(this.key);
    return data ? JSON.parse(data) : null;
  }

  async setTokens(tokens: OAuthTokens): Promise<void> {
    await redis.set(this.key, JSON.stringify(tokens));
  }

  async clearTokens(): Promise<void> {
    await redis.del(this.key);
  }
}
```

### Python

```python
import dataclasses
import json

from utilia_sdk import OAuthTokens, TokenStorage


class RedisTokenStorage(TokenStorage):
    def __init__(self, redis_client, key: str) -> None:
        self._redis = redis_client
        self._key = key

    async def get_tokens(self) -> OAuthTokens | None:
        data = await self._redis.get(self._key)
        return OAuthTokens(**json.loads(data)) if data else None

    async def set_tokens(self, tokens: OAuthTokens) -> None:
        await self._redis.set(self._key, json.dumps(dataclasses.asdict(tokens)))

    async def clear_tokens(self) -> None:
        await self._redis.delete(self._key)
```

Un cliente con OAuth guarda **una sola sesión**: el almacenamiento no distingue entre personas. En un servidor que atiende a varias, crea un cliente por persona o un almacenamiento con una clave por sesión, como en el ejemplo.

## Renovación y caducidad

Antes de cada petición, si al token de acceso le quedan menos de 30 segundos, el SDK lo renueva con el token de renovación. Si aun así el servidor responde 401, lo renueva y repite la petición una vez (en Python, solo cuando `retry_attempts` es mayor que 1). Las renovaciones simultáneas se agrupan en una sola, porque el servidor rota el token de renovación y dos usos del mismo invalidarían la sesión.

Cuando ya no se puede renovar, la petición falla con `UNAUTHORIZED`: es el momento de pedir a la persona que vuelva a autorizar.

### JavaScript

```typescript
import { ErrorCode, UtiliaSDKError } from '@utilia-os/sdk-js';

try {
  await sdk.tickets.list('user-456');
} catch (error) {
  if (error instanceof UtiliaSDKError && error.code === ErrorCode.UNAUTHORIZED) {
    window.location.href = await sdk.oauth.getAuthorizationUrl();
  }
}
```

### Python

```python
from utilia_sdk import ErrorCode, UtiliaSDKError

try:
    await sdk.tickets.list("user-456")
except UtiliaSDKError as error:
    if error.code == ErrorCode.UNAUTHORIZED:
        auth_url = await sdk.oauth.get_authorization_url()
        print(f"La sesión ha caducado. Vuelve a autorizar: {auth_url}")
```

`ErrorCode` incluye el valor `AUTH_EXPIRED`, pero hoy el SDK no lo emite nunca: una sesión caducada llega siempre como `UNAUTHORIZED`. No bases la lógica de volver a autorizar en `AUTH_EXPIRED`.

## Otras operaciones de la sesión

| JavaScript | Python | Qué hace |
| - | - | - |
| `isAuthenticated()` | `is_authenticated()` | `true` si hay un token vigente o uno de renovación. |
| `getUserInfo()` | `get_user_info()` | Datos de la persona (`sub`, `name`, `email`…). |
| `getAccessToken()` | `get_access_token()` | Token de acceso vigente, renovado si hacía falta. |
| `refreshToken()` | `refresh_token()` | Fuerza la renovación. |
| `logout()` | `logout()` | Revoca el token de acceso y borra el almacenamiento. |
| `revokeToken(tipo?)` | `revoke_token(tipo)` | Revoca el token de acceso o el de renovación y borra el almacenamiento. |
| `revoke(token, pista?)` | `revoke(token, token_type_hint)` | Revoca un token cualquiera sin tocar el almacenamiento. |
| `introspect(token, pista?)` | `introspect(token, token_type_hint)` | Consulta el estado de un token emitido a tu aplicación. Solo para aplicaciones confidenciales: sin `clientSecret` falla antes de llamar al servidor. |
| `getAuthorizedApps()` | `get_authorized_apps()` | Aplicaciones que la persona ha autorizado, con permisos ya recortados por el plan. |
| `revokeApp(clientId)` | `revoke_app(client_id)` | Retira la autorización de una aplicación e invalida sus tokens. |

## Ventana emergente (solo JavaScript en el navegador)

`loginWithPopup()` abre la autorización en una ventana emergente y devuelve los tokens sin que tu página cambie. Acepta las mismas opciones que la dirección de autorización más `width`, `height` y `timeout` (por defecto 500 × 700 píxeles y 5 minutos).

Para que funcione, la página de tu `redirectUri` tiene que avisar a la ventana que la abrió, desde el mismo origen, con `window.opener.postMessage({ type: 'oauth-mcp-success', code, state }, origen)` (o `type: 'oauth-mcp-error'` con `error` si la persona rechaza). Si el navegador bloquea la ventana, la persona la cierra o se agota el tiempo, la promesa falla con un error explicativo.

## Siguiente paso

- [Scopes y recursos](https://os.utilia.ai/docs/developers/oauth/scopes-and-resources): Qué pedir en scopes y cómo se recortan por el plan de la organización.
- [Gestionar errores](https://os.utilia.ai/docs/developers/sdk/error-handling): Qué trae un UtiliaSDKError y cuándo reintentar.
