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.
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.goyfacturas.go; cambianinternal/api/api.goeinternal/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(desdecourse/)
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ónde | Cambio |
|---|---|
| Realm | Client facturacion (confidencial, con service account); rol de client facturar en api-pedidos, asignado a esa service account. |
internal/apiauth | El 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/api | Nueva ruta GET /facturacion/pedidos?status=… para servicios con el rol facturar. |
cmd/facturacion, internal/facturacion | El 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.
| Authorization Code (tienda-web) | Client Credentials (facturacion) | |
|---|---|---|
| Quién actúa | Un usuario, a través de la app | El propio servicio |
sub del token | El usuario (ana) | La service account del client |
| Navegador / login | Sí | No |
| Refresh token | Sí | No: cuando caduca, se pide otro con el secreto |
Sesión SSO (sid) | Sí | No |
| Permisos | Roles del usuario | Roles 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"
]
}
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)" }
]
}
}
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:
| Token | Petición | Resultado |
|---|---|---|
servicio facturacion | GET /facturacion/pedidos | 200 |
servicio facturacion | GET /pedidos | 403 «hace falta uno de estos roles: [cliente admin]» |
servicio facturacion | GET /admin/pedidos | 403 «hace falta uno de estos roles: [admin]» |
carlos (admin) | GET /facturacion/pedidos | 403 «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.
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í.
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.