# Autenticación y scopes

> OAuth2 client_credentials: cómo pedir un token, qué scopes existen, cómo rotar el client_secret sin cortes y qué errores devuelve el endpoint del token.

Página: https://safeonuba.com/desarrolladores/autenticacion · API de SafeOnuba v1 (`api_version: "2026-09-24"`), en vista previa: el contrato puede cambiar.

La API usa OAuth2 con el flujo `client_credentials`: tu servidor cambia sus credenciales por un token de una hora y manda ese token en cada llamada.

## Credenciales

Cada cliente se da de alta a mano, con un motivo que queda registrado. Al darte de alta recibes:

- un `client_id`, que identifica a tu empresa;
- un `client_secret`, que empieza por `cs_` y **se enseña una sola vez**.

Un cliente puede tener **dos secretos activos a la vez**. Es lo que permite rotarlo sin cortar la integración (ver [Rotar el secreto](https://safeonuba.com/desarrolladores/autenticacion#rotar-el-client-secret)).

El `client_secret` vive en tu servidor, en un gestor de secretos o una variable de entorno. Nunca en un navegador, una aplicación de escritorio o un repositorio.

## Pedir un token

`POST /v1/oauth/token`, con el cuerpo en `application/x-www-form-urlencoded`:

| Campo | Obligatorio | Valor |
| --- | --- | --- |
| `grant_type` | Sí | `client_credentials` |
| `client_id` | Sí | Tu identificador de cliente. |
| `client_secret` | Sí | Tu secreto. |
| `scope` | No | Scopes separados por espacios, entre los que tiene tu cliente. Sin él, el token lleva todos los de tu cliente. |

```bash
curl -X POST https://sandbox.api.safeonuba.com/v1/oauth/token \
  -d grant_type=client_credentials \
  -d client_id="$SAFEONUBA_CLIENT_ID" \
  -d client_secret="$SAFEONUBA_CLIENT_SECRET" \
  -d scope="alerts:read musters:read"
```

```javascript
const res = await fetch('https://sandbox.api.safeonuba.com/v1/oauth/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: process.env.SAFEONUBA_CLIENT_ID,
    client_secret: process.env.SAFEONUBA_CLIENT_SECRET,
    scope: 'alerts:read musters:read',
  }),
});
const token = await res.json();
```

```python
import os
import requests

res = requests.post(
    "https://sandbox.api.safeonuba.com/v1/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": os.environ["SAFEONUBA_CLIENT_ID"],
        "client_secret": os.environ["SAFEONUBA_CLIENT_SECRET"],
        "scope": "alerts:read musters:read",
    },
    timeout=10,
)
res.raise_for_status()
token = res.json()
```

```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/oauth/token",
    new FormUrlEncodedContent(new Dictionary<string, string>
    {
        ["grant_type"] = "client_credentials",
        ["client_id"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_ID")!,
        ["client_secret"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_SECRET")!,
        ["scope"] = "alerts:read musters:read",
    }));
res.EnsureSuccessStatusCode();
var token = await res.Content.ReadFromJsonAsync<JsonElement>();
```

La respuesta:

| Campo | Qué es |
| --- | --- |
| `access_token` | El token. Es opaco: no intentes leerlo, solo reenvíalo. |
| `token_type` | Siempre `Bearer`. |
| `expires_in` | Segundos de validez: `3600`. |
| `scope` | Los scopes que lleva el token, separados por espacios. |

El token se revoca al instante si se da de baja al cliente.

## Usar el token

En todas las demás llamadas, en la cabecera `Authorization`:

```http
GET /v1/alerts?status=unacknowledged HTTP/1.1
Host: sandbox.api.safeonuba.com
Authorization: Bearer <token>
```

## Renovarlo

`client_credentials` no tiene token de refresco: cuando uno caduca se pide otro con las mismas credenciales. Pide uno, guárdalo en memoria y renuévalo un poco antes de que caduque. No pidas un token por cada llamada: el endpoint del token tiene su propio límite por IP.

```javascript
let token = null;
let caduca = 0;

export async function tokenSafeOnuba() {
  // Se renueva un minuto antes de caducar.
  if (token && Date.now() < caduca - 60_000) return token;
  const res = await fetch('https://sandbox.api.safeonuba.com/v1/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.SAFEONUBA_CLIENT_ID,
      client_secret: process.env.SAFEONUBA_CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
  const datos = await res.json();
  token = datos.access_token;
  caduca = Date.now() + datos.expires_in * 1000;
  return token;
}
```

```python
import os
import time
import requests

_token = None
_caduca = 0.0

def token_safeonuba() -> str:
    global _token, _caduca
    # Se renueva un minuto antes de caducar.
    if _token and time.time() < _caduca - 60:
        return _token
    res = requests.post(
        "https://sandbox.api.safeonuba.com/v1/oauth/token",
        data={
            "grant_type": "client_credentials",
            "client_id": os.environ["SAFEONUBA_CLIENT_ID"],
            "client_secret": os.environ["SAFEONUBA_CLIENT_SECRET"],
        },
        timeout=10,
    )
    res.raise_for_status()
    datos = res.json()
    _token = datos["access_token"]
    _caduca = time.time() + datos["expires_in"]
    return _token
```

```csharp
using System.Net.Http.Json;
using System.Text.Json;

public sealed class TokenSafeOnuba(HttpClient http)
{
    private readonly SemaphoreSlim _cerrojo = new(1, 1);
    private string? _token;
    private DateTimeOffset _caduca;

    public async Task<string> ObtenerAsync()
    {
        await _cerrojo.WaitAsync();
        try
        {
            // Se renueva un minuto antes de caducar.
            if (_token is not null && DateTimeOffset.UtcNow < _caduca.AddMinutes(-1)) return _token;
            var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/oauth/token",
                new FormUrlEncodedContent(new Dictionary<string, string>
                {
                    ["grant_type"] = "client_credentials",
                    ["client_id"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_ID")!,
                    ["client_secret"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_SECRET")!,
                }));
            res.EnsureSuccessStatusCode();
            var datos = await res.Content.ReadFromJsonAsync<JsonElement>();
            _token = datos.GetProperty("access_token").GetString()!;
            _caduca = DateTimeOffset.UtcNow.AddSeconds(datos.GetProperty("expires_in").GetDouble());
            return _token;
        }
        finally
        {
            _cerrojo.Release();
        }
    }
}
```

Si una llamada devuelve `401` con un token que creías vigente (por ejemplo, porque se ha revocado), descarta el que tienes y pide otro una vez. Si vuelve a fallar, no reintentes en bucle: revisa las credenciales.

## Scopes

Cada token lleva los scopes que tu cliente tiene concedidos, o el subconjunto que pidas en `scope`. Pide solo los que usa cada servicio: el receptor de webhooks, por ejemplo, no necesita ninguno, porque no llama a la API.

| Scope | Para qué | Operaciones |
| --- | --- | --- |
| `alerts:read` | Leer alertas y zonas. | `GET /v1/sites/{id}/zones`, `GET /v1/alerts`, `GET /v1/alerts/{id}` |
| `alerts:write` | Reconocer y cerrar alertas (siempre con el operador). | `PUT /v1/alerts/{id}` |
| `positions:read` | Posiciones que el velo deja ver. | `GET /v1/positions/latest` |
| `musters:read` | Evacuaciones y recuento. | `GET /v1/musters`, `GET /v1/musters/{id}`, `GET /v1/musters/{id}/missing` |
| `workers:read` | Trabajadores y relojes. | `GET /v1/workers`, `GET /v1/workers/{id}`, `GET /v1/devices` |
| `webhooks:manage` | Destinos de webhook. | `GET /v1/webhooks`, `POST /v1/webhooks`, `DELETE /v1/webhooks/{id}`, `POST /v1/webhooks/{id}/rotate-secret`, `POST /v1/webhooks/{id}/test` |

`GET /v1/sites`, `GET /v1/events` y `POST /v1/sandbox/scenarios` valen con cualquier token válido.

`alerts:write` siempre actúa en nombre de una persona: el cuerpo de `PUT /v1/alerts/{id}` exige el `actor`, el operador de tu sala que reconoce o cierra la alerta.

## Rotar el client_secret

Como un cliente puede tener dos secretos activos, la rotación no corta nada:

1. Pide un secreto nuevo por el mismo canal por el que pediste el acceso.
2. Despliega el nuevo en tus servicios. Mientras tanto, los que aún usan el antiguo siguen funcionando.
3. Comprueba que todos los tokens se piden ya con el nuevo.
4. Pide que se retire el antiguo.

El secreto de firma de los webhooks es otra cosa y se rota por la API: ver [Verificar la firma](https://safeonuba.com/desarrolladores/webhooks/verificar-firma#rotar-el-secreto).

## Errores del token

El endpoint del token responde en el formato de OAuth2 ([RFC 6749 §5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2)), no en `problem+json`: un campo `error` y, a veces, `error_description`. Responde `401` si el cliente o el secreto no son correctos, y `400` para lo demás.

| error | Qué significa |
| --- | --- |
| `invalid_request` | Falta un campo obligatorio o la petición está mal formada. |
| `invalid_client` | El client_id o el client_secret no son correctos, o el cliente se ha dado de baja. |
| `unauthorized_client` | El cliente no está autorizado a usar este tipo de concesión. |
| `unsupported_grant_type` | grant_type no es client_credentials. |
| `invalid_scope` | Se ha pedido un scope que no existe o que el cliente no tiene. |

En el resto de la API, un token que falta, ha caducado o se ha revocado da `401`, y un token sin el scope que pide la ruta da `403`. Ver [Errores](https://safeonuba.com/desarrolladores/errores).
