
# SDK de servidor para Go

El módulo `github.com/keenauth/keenauth-go` es el cliente de Go de la API de gestión de KeenAuth. Los métodos tipados para cada recurso de `/v1` se generan a partir del documento OpenAPI (paquete `management`, incluido en `keenauth.Client`). El núcleo escrito a mano añade autenticación, reintentos, claves de idempotencia, errores tipados y advertencias de obsolescencia. El módulo solo usa la biblioteca estándar. Todavía no está publicado.

Para el inicio de sesión en una aplicación `net/http`, consulte [Sesiones de Go net/http](/docs/sdk/go-nethttp). Para proteger su propia API, consulte [Protección de su API](/docs/sdk/protect-api).

## Crear un cliente con una clave de API

```go
import (
	"context"
	"fmt"
	"os"

	keenauth "github.com/keenauth/keenauth-go"
)

c, err := keenauth.NewClient("https://api.eu.keenauth.com/v1", os.Getenv("KEENAUTH_API_KEY"))
if err != nil {
	panic(err)
}
user, _, err := c.UsersAPI.GetUser(context.Background(), "usr_01j9zzzzzzzzzzzzzzzzzzzz").Execute()
if err != nil {
	panic(err)
}
fmt.Println(user.GetEmail())
```

La URL base es la dirección de la API de su región seguida de `/v1`. La clave (`idp_sk_…` o `idp_rk_…`) se envía solo en la cabecera `Authorization` y solo a esa dirección; una redirección a otro host o a `http` sin cifrar nunca la transmite. El `http` sin cifrar solo se acepta para `localhost`.

Opciones:

- `keenauth.WithHTTPClient(h)`: su propio `http.Client` (su transporte se envuelve, no se cambia).
- `keenauth.WithTimeout(d)`: límite de tiempo total de una llamada, incluidos los reintentos (30 segundos por defecto).
- `keenauth.WithClientName("my-tool", "1.4.0")`: añade su herramienta a `User-Agent` e `Idp-Client`.
- `keenauth.WithLogger(l)`: el `*slog.Logger` para las advertencias del SDK (por defecto, `slog.Default()`).

## Autenticarse con private_key_jwt

En lugar de una clave estática, una aplicación de gestión puede iniciar sesión con su propio par de claves. El SDK obtiene un token de gestión del endpoint de tokens de su entorno, lo mantiene en memoria y lo sustituye 60 segundos antes de que caduque. Las llamadas simultáneas comparten una única solicitud de token.

1. En el panel, cree una aplicación de gestión y registre su clave pública (como URL de JWKS o directamente; hasta 3 claves activas).
2. Guarde la clave privada en su servidor, por ejemplo en un archivo de secretos o en un KMS.

```go
import (
	"os"

	keenauth "github.com/keenauth/keenauth-go"
	"github.com/keenauth/keenauth-go/assertion"
)

pemBytes, err := os.ReadFile("/run/secrets/keenauth-mgmt-key.pem")
if err != nil {
	panic(err)
}
key, err := assertion.ParsePrivateKeyPEM(pemBytes)
if err != nil {
	panic(err)
}
c, err := keenauth.NewClientWithPrivateKeyJWT("https://api.eu.keenauth.com/v1", keenauth.PrivateKeyJWT{
	Issuer:   "https://acme.eu.keenauth.app", // el emisor de su entorno, exactamente
	ClientID: "app_01j9zzzzzzzzzzzzzzzzzzzz",
	Key:      key,
	KeyID:    "mgmt-key-2026-11", // el kid que registró; opcional con una sola clave
	Scope:    "users:read users:write", // opcional; por defecto: los ámbitos predeterminados de la concesión
})
```

Lo que envía el SDK:

- `POST <Issuer>/oauth2/token` con `grant_type=client_credentials`, `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, un nuevo `client_assertion` y `resource` con la dirección de la API (`https://api.eu.keenauth.com`; cámbielo con `Audience`).
- La aserción es un JWT con `iss` y `sub` con su ID de cliente, `aud` con el emisor, un `jti` aleatorio y una duración de 2 minutos. Se firma con ES256 (clave P-256), EdDSA (Ed25519), RS256 o PS256 (RSA, 2048 bits o más; use `Algorithm: "PS256"` para PSS).

`Key` es cualquier `crypto.Signer`, por lo que una clave almacenada en un KMS o HSM funciona sin exportarla. El token de gestión tiene una validez de 10 minutos. Si la API responde `401`, el SDK descarta el token en caché y la siguiente llamada obtiene uno nuevo.

Una solicitud de token rechazada devuelve un `*keenauth.TokenError` con el estado HTTP y el código `error` de OAuth, por ejemplo `invalid_client` para una clave desconocida o un `KeyID` incorrecto. Nunca contiene la aserción ni un token:

```go
var te *keenauth.TokenError
if errors.As(err, &te) {
	log.Printf("token request refused: %d %s", te.Status, te.Code)
}
```

## Reintentos y claves de idempotencia

El SDK reintenta una llamada como máximo 3 veces en total, y solo cuando se cumplen ambas condiciones:

- la respuesta es `429`, `502`, `503` o `504`, o la conexión falló (un error de red, no de certificado);
- el método es `GET`, `PUT` o `DELETE`, o un `POST` con `Idempotency-Key`.

Cada operación `POST` que acepta `Idempotency-Key` (todas excepto las llamadas sin efectos secundarios `users:search` y `…:preview`, y `users/{id}:unlock`) recibe una nueva clave UUIDv4, y cada reintento de esa llamada envía la misma clave y el mismo cuerpo, de modo que la operación se ejecuta como máximo una vez. Para que una llamada sea idempotente a través de sus propios reinicios, pase su propia clave (de 16 a 64 caracteres de `A-Z a-z 0-9 _ -`), por ejemplo `.IdempotencyKey(orderKey)`; el SDK nunca la sustituye. Una `PATCH` que incluye `If-Match` (toda actualización con versión) solo se reintenta ante `429`: el limitador de frecuencia rechaza la solicitud antes de ejecutarla, y `If-Match` evita que un cambio se aplique dos veces. Ninguna otra `PATCH` se reintenta nunca.

Entre intentos, el SDK espera lo que indique `Retry-After` (segundos o una fecha HTTP). Si no existe, espera 0,5 segundos, después 1 segundo, cada uno con una variación de ±25 %. Si `Retry-After` pide más de 60 segundos, o la espera superaría la fecha límite de su contexto, el SDK devuelve la respuesta en lugar de esperar. Cancelar el contexto detiene la espera.

## Recorrer una lista

Los métodos de lista devuelven una página: `data` y `list_metadata` con los cursores `before` y `after`. `keenauth.All` sigue `after` y produce todos los elementos. Solo solicita la página siguiente cuando llega a ella, por lo que salir del bucle detiene las solicitudes posteriores. Configure el tamaño de página y los filtros en la solicitud como de costumbre:

```go
for u, err := range keenauth.All(c.UsersAPI.ListUsers(ctx).Limit(100).Status("active")) {
	if err != nil {
		return err // la iteración termina después de un error
	}
	fmt.Println(u.GetId(), u.GetEmail())
}
```

`keenauth.AllBefore` sigue `before` en su lugar, por ejemplo hacia las entradas más recientes de `ListLogs`. Todos los métodos de lista generados funcionan con ambos, excepto `ListEvents` de la API de eventos, que pagina con `next_cursor` y `has_more`. Un bucle de función por rango necesita Go 1.23 o posterior.

## Verificar webhooks

El paquete `github.com/keenauth/keenauth-go/webhook` comprueba la firma de una entrega de webhook (Standard Webhooks). Proporcione el secreto de firma del endpoint (`idp_whsec_…`) y pase el cuerpo sin procesar exactamente como se recibió:

```go
import (
	"io"
	"net/http"
	"os"

	"github.com/keenauth/keenauth-go/webhook"
)

verifier, err := webhook.NewVerifier([]string{os.Getenv("KEENAUTH_WEBHOOK_SECRET")})
if err != nil {
	panic(err) // webhook.ErrSecret: el secreto no tiene un formato correcto
}

http.HandleFunc("POST /webhooks/keenauth", func(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
		return
	}
	if err := verifier.Verify(r.Header, body); err != nil {
		http.Error(w, "invalid webhook", http.StatusBadRequest)
		return
	}
	// Procese el evento. Guarde webhook-id y omita una entrega que ya haya procesado.
	w.WriteHeader(http.StatusNoContent)
})
```

`Verify` acepta una entrega cuando `webhook-timestamp` está a menos de 5 minutos de la hora actual (en ambas direcciones) y una de las firmas `v1` de `webhook-signature` coincide. Las firmas se comparan en tiempo constante, y las demás versiones de firma se ignoran. El error indica por qué se rechazó una entrega; cada uno corresponde a uno de `webhook.ErrHeaders` (falta una cabecera), `webhook.ErrTimestamp`, `webhook.ErrSignature` o `webhook.ErrSecret`, que puede comprobar con `errors.Is`.

Al rotar el secreto, pase ambos secretos, el nuevo y el antiguo, hasta que el antiguo deje de usarse: `webhook.NewVerifier([]string{newSecret, oldSecret})`. Durante la rotación, KeenAuth también puede enviar dos firmas en una misma cabecera; basta con que una de ellas sea válida.

## Eventos tipados y claims

El paquete `github.com/keenauth/keenauth-go/events` tiene una estructura por cada tipo de evento de cliente, generada a partir de los esquemas públicos de eventos. `webhook.Verifier.VerifyEvent` verifica una entrega y la analiza; `events.Parse` analiza un cuerpo que ya haya verificado. Use un switch por tipo para obtener los campos de un tipo:

```go
ev, err := verifier.VerifyEvent(r.Header, body)
if err != nil {
	http.Error(w, "invalid webhook", http.StatusBadRequest) // webhook.Err… o events.ErrParse
	return
}
switch e := ev.(type) {
case *events.UserCreated:
	log.Printf("user %s created", e.Data.Object.ID)
case *events.EmailSendingSuspended:
	log.Printf("sending suspended: %s", e.Data.Object.Reason)
case *events.Unknown:
	// Un tipo que esta versión del SDK aún no conoce. e.Type y e.Raw() siguen disponibles.
}
```

- `Parse` comprueba el sobre (`id`, `type`, `schema_version`, `environment_id`, `occurred_at`, `recorded_at`, `seq`, `data`, `context`) y los miembros que declara el esquema del tipo. Un miembro ausente o de tipo incorrecto devuelve un `*events.ParseError` con su ruta, como `data.object.reason`.
- Un tipo desconocido, o un tipo conocido con un `schema_version` que este SDK no modela, se devuelve como `*events.Unknown`. No es un error.
- Se conservan los miembros que el esquema no declara: `Raw()` devuelve el evento completo y `ObjectJSON()` devuelve `data.object` (en el modo de carga completa, la representación del recurso de la API de gestión).
- Los valores de los campos enumerados no se comprueban contra los valores conocidos, así que un valor nuevo del servidor no interrumpe el análisis.

El paquete `github.com/keenauth/keenauth-go/claims` tipifica los claims de un token verificado: `claims.ParseIDToken` para un ID token (OpenID Connect Core) y `claims.ParseAccessToken` para un access token (RFC 9068). Comprueban los tipos de los claims, pero no verifican nada, así que llámelos sobre el mapa `Claims` que devuelve el paquete `verify`:

```go
at, err := v.VerifyAccessToken(ctx, token, nil)
if err != nil {
	return err
}
c, err := claims.ParseAccessToken(at.Claims)
if err != nil {
	return err
}
log.Printf("client %s, org %s, roles %v", c.ClientID, c.OrgID, c.OrgRoles)
_ = c.Custom["tenant_tier"] // sus propios claims
```

`aud` es siempre un slice (`claims.Audience`). Los claims que no forman parte del contrato son sus propios claims y se conservan en `Custom`.

## Errores

Un error de la API es un documento de problema RFC 9457. `keenauth.AsProblem(err)` lo devuelve con el `Code` legible por máquina. Un código desconocido se devuelve tal cual, así que los nuevos códigos del servidor no interrumpen su cliente:

```go
if _, _, err := c.UsersAPI.GetUser(ctx, id).Execute(); err != nil {
	if pe, ok := keenauth.AsProblem(err); ok && pe.Code == "not_found" {
		// …
	}
}
```

## Advertencias de obsolescencia

Cuando una respuesta incluye una cabecera `Deprecation` (RFC 9745) o `Sunset` (RFC 8594), el SDK registra una advertencia por operación y valor de cabecera para todo el proceso, con nivel `WARN` en el logger que configuró con `WithLogger`. La advertencia indica el método y la ruta con los identificadores sustituidos por `{id}`, los valores de las cabeceras y cualquier `Link` con `rel="deprecation"` o `rel="sunset"`. Nunca contiene IDs, tokens ni claves, y la llamada en sí no se ve afectada.

```text
level=WARN msg="keenauth: the API operation is deprecated" operation="GET /users/{id}" deprecation=@1767225600 sunset="Wed, 01 Jul 2026 00:00:00 GMT"
```
