Módulo 4 · Lección 07

Client Credentials

No todo lo que llama a una API es una persona. facturacion es un servicio interno que cada 30 segundos factura los pedidos entregados: se identifica ante Keycloak con su propio client y llama a api-pedidos con los permisos de su service account.

≈ 60 min Carpeta: tienda/pasos/paso-07 Realm nuevo: hay que reimportar

En este capítulo

Trabajas en
facturacion (nuevo servicio sin usuario) y api-pedidos, que le da acceso con el rol facturar.
Carpeta
tienda/pasos/paso-07 · realm nuevo
Archivos
Nuevos: cmd/facturacion/main.go, internal/facturacion/worker.go y facturas.go; cambian internal/api/api.go e internal/apiauth/ · ver todos los cambios del paso.
En marcha
Keycloak del paso 07, api, web y go run ./cmd/facturacion.
Comprueba
go -C tools/comprobar run . -paso 7 -servicios (desde course/)

Al terminar sabrás

  • Cuándo usar Client Credentials y en qué se diferencia de los flujos con usuario.
  • Configurar en Keycloak un client con service account y darle permisos con roles de client.
  • Obtener, cachear y renovar tokens de servicio en Go con golang.org/x/oauth2/clientcredentials.
  • Autorizar en la API a servicios con roles de client (resource_access).

Qué cambia

DóndeCambio
RealmClient facturacion (confidencial, con service account); rol de client facturar en api-pedidos, asignado a esa service account.
internal/apiauthEl Principal trae los roles de client de la API (APIRoles); nuevo RequireAPIRole. El realm de WWW-Authenticate es ahora la audiencia, porque facturacion reutilizará el paquete en la lección 8.
internal/apiNueva ruta GET /facturacion/pedidos?status=… para servicios con el rol facturar.
cmd/facturacion, internal/facturacionEl servicio nuevo: facturas en memoria y el proceso automático.
cd tienda/pasos/paso-06/infra && docker compose down -v
cd ../../paso-07/infra && docker compose up -d

1. El flujo más sencillo

Sin navegador, sin redirecciones y sin usuario: el servicio presenta su client ID y su secreto en el endpoint de token y recibe un access token.

Secuencia Client Credentials: facturacion pide un token a Keycloak y llama a api-pedidos con él facturacion Keycloak api-pedidos 1 POST /token grant_type=client_credentials + id + secreto 2 access token (5 min) · sin refresh token 3 GET /facturacion/pedidos Authorization: Bearer <token del servicio> 4 firma, aud, exp y el rol de API «facturar» 5 200 · pedidos entregados de todos los clientes cada 30 s repite 3–5 con el mismo token hasta que caduca; entonces repite 1–2
Naranja: el secreto solo viaja al endpoint de token de Keycloak.
Authorization Code (tienda-web)Client Credentials (facturacion)
Quién actúaUn usuario, a través de la appEl propio servicio
sub del tokenEl usuario (ana)La service account del client
Navegador / loginSíNo
Refresh tokenSíNo: cuando caduca, se pide otro con el secreto
Sesión SSO (sid)SíNo
PermisosRoles del usuarioRoles de la service account

2. El client y su service account

facturacion es un client confidencial con una sola capacidad: Service account roles, que es como la consola llama a Client Credentials.

{
  "clientId": "facturacion",
  "name": "Servicio de facturación",
  "description": "Servicio interno: Client Credentials (lección 7)",
  "enabled": true,
  "protocol": "openid-connect",
  "publicClient": false,
  "clientAuthenticatorType": "client-secret",
  "secret": "facturacion-secret",
  "standardFlowEnabled": false,
  "implicitFlowEnabled": false,
  "directAccessGrantsEnabled": false,
  "serviceAccountsEnabled": true,
  "defaultClientScopes": [
    "web-origins",
    "acr",
    "profile",
    "roles",
    "basic",
    "email",
    "api-pedidos"
  ],
  "optionalClientScopes": [
    "address",
    "phone",
    "organization",
    "offline_access",
    "microprofile-jwt"
  ]
}
Capability config de facturacion con Service account roles activado
Clients → facturacion: confidencial y solo Service account roles.

Al activar la service account, Keycloak crea un usuario especial, service-account-facturacion, que representa al client. Sus permisos son roles asignados a ese usuario. En el JSON van así:

{
  "username": "service-account-facturacion",
  "enabled": true,
  "serviceAccountClientId": "facturacion",
  "realmRoles": [
    "default-roles-tienda"
  ],
  "clientRoles": {
    "api-pedidos": [
      "facturar"
    ]
  }
}
"roles": {
  "realm": [ … ],
  "client": {
    "api-pedidos": [
      { "name": "facturar", "description": "Leer los pedidos de todos los clientes para facturarlos (para servicios)" }
    ]
  }
}
Service account roles de facturacion con el rol facturar de api-pedidos
Clients → facturacion → Service account roles: el rol de client facturar de api-pedidos.

Pídele un token a mano para ver qué contiene:

curl -s -u facturacion:facturacion-secret -d grant_type=client_credentials \
  http://localhost:8080/realms/tienda/protocol/openid-connect/token
# {"access_token":"eyJ…","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":"email profile"}
{
  "aud": ["api-pedidos", "account"],
  "sub": "26693eee-ae52-4c5a-b3cf-8f89a3624985",
  "typ": "Bearer",
  "azp": "facturacion",
  "realm_access": { "roles": ["offline_access", "uma_authorization", "default-roles-tienda"] },
  "resource_access": {
    "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] },
    "api-pedidos": { "roles": ["facturar"] }
  },
  "scope": "email profile",
  "preferred_username": "service-account-facturacion"
}

Fíjate en que refresh_expires_in vale 0 (no hay refresh token), no hay sid y el rol facturar aparece en resource_access.api-pedidos. La audiencia api-pedidos sale del mismo client scope de la lección 5, asignado como Default a facturacion.

3. Roles de client en la API

¿Por qué no darle a la service account el rol admin? Porque sería darle muchísimo más de lo que necesita: cambiar estados, ver la página de administración… Mínimo privilegio: un rol que solo signifique «puede leer pedidos para facturar» y que pertenezca a la API que lo interpreta. Por eso es un rol de client de api-pedidos, no uno de realm.

En apiauth, el Verifier recuerda su audiencia y saca del token los roles de client de esa API:

// Verify comprueba un access token y devuelve a quién representa.
func (v *Verifier) Verify(ctx context.Context, raw string) (*Principal, error) {
	tok, err := v.verifier.Verify(ctx, raw)
	if err != nil {
		return nil, err
	}
	var c struct {
		Typ         string `json:"typ"`
		Username    string `json:"preferred_username"`
		AZP         string `json:"azp"`
		Scope       string `json:"scope"`
		RealmAccess struct {
			Roles []string `json:"roles"`
		} `json:"realm_access"`
		ResourceAccess map[string]struct {
			Roles []string `json:"roles"`
		} `json:"resource_access"`
	}
	if err := tok.Claims(&c); err != nil {
		return nil, err
	}
	// Keycloak marca cada token con su tipo. Un ID token o un refresh token
	// nunca deben servir para llamar a la API.
	if c.Typ != "Bearer" {
		return nil, fmt.Errorf("no es un access token (typ=%q)", c.Typ)
	}
	return &Principal{
		Subject:  tok.Subject,
		Username: c.Username,
		ClientID: c.AZP,
		Scopes:   strings.Fields(c.Scope),
		Roles:    c.RealmAccess.Roles,
		APIRoles: c.ResourceAccess[v.audience].Roles,
	}, nil
}
// RequireAPIRole deja pasar solo si el token trae el rol de client de esta API
// (resource_access.<audiencia>.roles). Es lo habitual para servicios: sus
// permisos se asignan a su service account como roles de client de la API.
func RequireAPIRole(role string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			p := FromContext(r.Context())
			if p == nil || !slices.Contains(p.APIRoles, role) {
				jsonhttp.Error(w, http.StatusForbidden, "forbidden", "hace falta el rol de API "+role)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}
// Register añade las rutas de la API. Cada ruta declara, de un vistazo,
// qué hace falta para llamarla: token válido + roles + scope.
func Register(mux *http.ServeMux, v *apiauth.Verifier, store *pedidos.Store) {
	h := &handlers{store: store}
	cliente := apiauth.RequireRole("cliente", "admin")
	admin := apiauth.RequireRole("admin")
	escribir := apiauth.RequireScope("pedidos:escribir")

	mux.Handle("GET /pedidos", protect(v, h.list, cliente))
	mux.Handle("GET /pedidos/{id}", protect(v, h.get, cliente))
	mux.Handle("POST /pedidos", protect(v, h.create, cliente, escribir))
	mux.Handle("GET /admin/pedidos", protect(v, h.listAll, admin))
	mux.Handle("PATCH /admin/pedidos/{id}", protect(v, h.setStatus, admin, escribir))

	// Para servicios (Client Credentials): rol de client de api-pedidos.
	mux.Handle("GET /facturacion/pedidos", protect(v, h.listByStatus, apiauth.RequireAPIRole("facturar")))
}

Los permisos no se cruzan, y es lo que queremos. Comprobado contra la API:

TokenPeticiónResultado
servicio facturacionGET /facturacion/pedidos200
servicio facturacionGET /pedidos403 «hace falta uno de estos roles: [cliente admin]»
servicio facturacionGET /admin/pedidos403 «hace falta uno de estos roles: [admin]»
carlos (admin)GET /facturacion/pedidos403 «hace falta el rol de API facturar»

4. El servicio en Go

El paquete golang.org/x/oauth2/clientcredentials (incluido en el módulo x/oauth2) hace casi todo. Con oauth2.NewClient obtienes un *http.Client normal que, en cada petición, añade el token. Lo pide la primera vez, lo reutiliza mientras sea válido y pide otro cuando caduca:

// Command facturacion es el servicio de facturación: un proceso interno que,
// sin ningún usuario delante, factura los pedidos entregados. Se autentica
// ante Keycloak como client confidencial (Client Credentials).
//
// Uso (desde tienda/pasos/paso-07):
//
//	go run ./cmd/facturacion
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/clientcredentials"

	"tienda/internal/facturacion"
)

func main() {
	issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
	apiURL := env("API_URL", "http://localhost:8081")
	interval, err := time.ParseDuration(env("INTERVALO", "30s"))
	if err != nil {
		log.Fatalf("INTERVALO: %v", err)
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	provider, err := oidc.NewProvider(ctx, issuer)
	if err != nil {
		log.Fatal(err)
	}

	// Client Credentials: el servicio se identifica con su client ID y su
	// secreto. No hay usuario, ni navegador, ni refresh token.
	cfg := clientcredentials.Config{
		ClientID:     env("OIDC_CLIENT_ID", "facturacion"),
		ClientSecret: env("OIDC_CLIENT_SECRET", "facturacion-secret"), // solo para desarrollo
		TokenURL:     provider.Endpoint().TokenURL,
	}

	// ReuseTokenSource guarda el token y solo pide otro cuando caduca.
	// loggingSource nos deja ver en el log cuándo ocurre eso.
	ts := oauth2.ReuseTokenSource(nil, loggingSource{cfg.TokenSource(ctx)})
	client := oauth2.NewClient(ctx, ts) // añade «Authorization: Bearer» a cada petición
	client.Timeout = 5 * time.Second

	w := &facturacion.Worker{
		API:      apiURL,
		HTTP:     client,
		Store:    facturacion.NewStore(),
		Interval: interval,
	}
	log.Printf("facturacion: facturando cada %s contra %s", interval, apiURL)
	w.Run(ctx)
}

// loggingSource registra cada vez que hace falta un token nuevo de Keycloak.
type loggingSource struct{ src oauth2.TokenSource }

func (l loggingSource) Token() (*oauth2.Token, error) {
	tok, err := l.src.Token()
	if err != nil {
		return nil, err
	}
	log.Printf("token de servicio nuevo (caduca a las %s)", tok.Expiry.Format("15:04:05"))
	return tok, nil
}

func env(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

El worker no sabe nada de tokens: usa el http.Client que le dan.

package facturacion

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

// Order es lo que el servicio necesita saber de un pedido de api-pedidos.
type Order struct {
	ID     int     `json:"id"`
	Owner  string  `json:"owner"`
	Total  float64 `json:"total"`
	Status string  `json:"status"`
}

// Worker factura automáticamente los pedidos entregados. No actúa en nombre
// de ningún usuario: llama a api-pedidos con el token de su propia service
// account, que va incluido en HTTP (ver cmd/facturacion).
type Worker struct {
	API      string       // URL base de api-pedidos
	HTTP     *http.Client // añade «Authorization: Bearer» con el token del servicio
	Store    *Store
	Interval time.Duration
}

// Run factura una vez al arrancar y después cada Interval, hasta que ctx termine.
func (w *Worker) Run(ctx context.Context) {
	t := time.NewTicker(w.Interval)
	defer t.Stop()
	for {
		w.runOnce(ctx)
		select {
		case <-ctx.Done():
			return
		case <-t.C:
		}
	}
}

func (w *Worker) runOnce(ctx context.Context) {
	orders, err := w.deliveredOrders(ctx)
	if err != nil {
		log.Printf("facturación automática: %v", err)
		return
	}
	nuevas := 0
	for _, o := range orders {
		if inv, created := w.Store.Issue(o.ID, o.Owner, o.Total, "automática"); created {
			nuevas++
			log.Printf("  %s → pedido #%d por $ %.2f", inv.Number, inv.OrderID, inv.Total)
		}
	}
	log.Printf("facturación automática: %d pedidos entregados, %d facturas nuevas", len(orders), nuevas)
}

// deliveredOrders pide a api-pedidos los pedidos entregados de todos los clientes.
func (w *Worker) deliveredOrders(ctx context.Context) ([]Order, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.API+"/facturacion/pedidos?status=Entregado", nil)
	if err != nil {
		return nil, err
	}
	resp, err := w.HTTP.Do(req)
	if err != nil {
		return nil, err // incluye los fallos al pedir el token a Keycloak
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		var e struct {
			Description string `json:"error_description"`
		}
		_ = json.NewDecoder(resp.Body).Decode(&e)
		return nil, fmt.Errorf("api-pedidos respondió %s: %s", resp.Status, e.Description)
	}
	var out struct {
		Pedidos []Order `json:"pedidos"`
	}
	err = json.NewDecoder(resp.Body).Decode(&out)
	return out.Pedidos, err
}
Ver internal/facturacion/facturas.go
// Package facturacion es el dominio del servicio de facturación: las
// facturas y el proceso que factura automáticamente los pedidos entregados.
package facturacion

import (
	"fmt"
	"sort"
	"sync"
	"time"
)

// Invoice es una factura de un pedido.
type Invoice struct {
	Number   string    `json:"numero"`  // F-0001, F-0002…
	OrderID  int       `json:"pedido"`  // un pedido tiene como mucho una factura
	Customer string    `json:"cliente"` // sub del cliente
	Total    float64   `json:"total"`
	Origin   string    `json:"origen"` // "automática" o "solicitada"
	IssuedAt time.Time `json:"emitida"`
}

// Store guarda las facturas en memoria, seguro para uso concurrente.
type Store struct {
	mu      sync.Mutex
	byOrder map[int]Invoice
	next    int
}

// NewStore crea un almacén vacío.
func NewStore() *Store {
	return &Store{byOrder: make(map[int]Invoice), next: 1}
}

// Issue emite la factura de un pedido. Si el pedido ya tenía factura, la
// devuelve con created=false: facturar dos veces lo mismo no crea otra.
func (s *Store) Issue(orderID int, customer string, total float64, origin string) (inv Invoice, created bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if inv, ok := s.byOrder[orderID]; ok {
		return inv, false
	}
	inv = Invoice{
		Number:   fmt.Sprintf("F-%04d", s.next),
		OrderID:  orderID,
		Customer: customer,
		Total:    total,
		Origin:   origin,
		IssuedAt: time.Now().UTC(),
	}
	s.byOrder[orderID] = inv
	s.next++
	return inv, true
}

// ByCustomer devuelve las facturas de un cliente, ordenadas por número.
func (s *Store) ByCustomer(sub string) []Invoice {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := []Invoice{}
	for _, inv := range s.byOrder {
		if inv.Customer == sub {
			out = append(out, inv)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Number < out[j].Number })
	return out
}

5. Probarlo

cd tienda/pasos/paso-07 && go run ./cmd/api
cd tienda/pasos/paso-07 && INTERVALO=5s go run ./cmd/facturacion
En PowerShell (Windows)
cd tienda/pasos/paso-07
$env:INTERVALO="5s"; go run ./cmd/facturacion

Salida real (con el realm recién importado, el único pedido entregado es el #1003 de carlos):

17:05:52 facturacion: facturando cada 5s contra http://localhost:8081
17:05:52 token de servicio nuevo (caduca a las 17:10:52)
17:05:52   F-0001 → pedido #1003 por $ 30.00
17:05:52 facturación automática: 1 pedidos entregados, 1 facturas nuevas
17:05:57 facturación automática: 1 pedidos entregados, 0 facturas nuevas
17:06:02 facturación automática: 1 pedidos entregados, 0 facturas nuevas
17:06:07 facturación automática: 1 pedidos entregados, 0 facturas nuevas

Un solo token para todas las vueltas. Para ver la renovación, bajamos Access Token Lifespan a 20 segundos:

17:06:22 token de servicio nuevo (caduca a las 17:06:42)
17:06:37 token de servicio nuevo (caduca a las 17:06:57)
17:06:52 token de servicio nuevo (caduca a las 17:07:12)

Cada token se sustituye unos segundos antes de caducar: x/oauth2 lo da por caducado 10 segundos antes de su exp, para que nunca llegue a la API uno que caduque por el camino.

Ahora entra en la tienda como carlos, ve a Admin (necesitas también go run ./cmd/web) y marca como Entregado otro pedido. En la siguiente vuelta, facturacion emitirá su factura.

El secreto es la identidad del servicio

Quien tenga facturacion-secret es el servicio de facturación. Fuera del curso: pásalo por una variable de entorno o un gestor de secretos, nunca en el código ni en Git; regenéralo (Credentials → Regenerate) si se filtra; y valora Signed JWT (private_key_jwt), donde el servicio firma con una clave privada que nunca envía.

Ejercicios

1. Quitar el permiso · fácil

En Clients → facturacion → Service account roles, quita facturar. ¿Qué escribe facturacion en la siguiente vuelta? Después, devuélvelo.

Ver solución

Si reinicias facturacion (para que pida un token nuevo):

facturación automática: api-pedidos respondió 403 Forbidden: hace falta el rol de API facturar

Si no lo reinicias, seguirá funcionando hasta que caduque el token que tiene en caché, que se emitió con el rol. Es el mismo retraso de revocación de la lección 4: como mucho, la vida del access token.

2. Rotar el secreto · fácil

Pulsa Regenerate en Clients → facturacion → Credentials sin reiniciar el servicio. ¿Cuándo empieza a fallar y con qué mensaje? ¿Cómo lo arreglas?

Ver solución

No falla al momento: el token en caché sigue siendo válido. Falla cuando necesita uno nuevo (a los 5 minutos como mucho), con el mismo error que da un secreto incorrecto:

facturación automática: Get "http://localhost:8081/facturacion/pedidos?status=Entregado": oauth2: "unauthorized_client" "Invalid client or Invalid client credentials"

Se arranca de nuevo con OIDC_CLIENT_SECRET=<el nuevo>. En producción, la rotación se planifica: Keycloak permite mantener el secreto anterior válido un tiempo mediante políticas de cliente.

3. Un segundo servicio · media

Quieres un servicio informes que solo cuente pedidos por estado. Sin tocar código Go, ¿qué crearías en Keycloak para que pueda leer GET /facturacion/pedidos? ¿Es buena idea reutilizar el client facturacion?

Ver solución

Un client informes confidencial con Service account roles, el scope api-pedidos como Default (para la audiencia) y el rol facturar en su service account. No reutilices facturacion: cada servicio con su client y su secreto te permite revocarlo por separado, ver en los logs (azp) quién llamó y darle solo lo que necesita. Si informes no debe ver lo mismo que facturación, crea otro rol (informar) y una ruta que lo exija.

Errores comunes

unauthorized_client «Invalid client or Invalid client credentials»

El secreto no coincide (¿lo regeneraste?) o el client ID está mal escrito. Arreglo: copia el secreto de Credentials.

unauthorized_client «Client not enabled to retrieve service account»

El client no tiene Service account roles activado (por ejemplo, si lo intentas con tienda-web). Arreglo: actívalo en Capability config, o usa el client correcto.

unauthorized_client «Public client not allowed to retrieve service account»

Un client público (como tienda-cli) no puede guardar un secreto, así que no puede tener service account. Arreglo: los servicios son siempre clients confidenciales.

403 «hace falta el rol de API facturar»

La service account no tiene el rol, o el token se emitió antes de asignárselo. Arreglo: asígnalo en Service account roles y espera a que el servicio pida un token nuevo (o reinícialo).

rendimiento Un token nuevo en cada petición

Llamar a cfg.Token(ctx) antes de cada petición a la API sin cachearlo multiplica la carga sobre Keycloak. Arreglo: usa cfg.Client(ctx) u oauth2.NewClient con un ReuseTokenSource, como en main.go, y crea ese cliente una vez al arrancar.

sin refresh token «¿Dónde está el refresh token?»

No lo hay, y es correcto: el servicio siempre puede pedir otro access token con su secreto. Keycloak permite activar refresh tokens para Client Credentials, pero no aportan nada aquí.

Resumen

Un servicio sin usuario es un client confidencial con service account. Pide tokens con su ID y su secreto (Client Credentials), los reutiliza hasta que caducan y no tiene refresh token. Sus permisos son roles de su service account; lo idiomático es usar roles de client de la API que los interpreta y aplicar el mínimo privilegio. En la lección 8, facturacion actuará en nombre de un usuario: Token Exchange.