Módulo 3 · Lección 05

Middleware JWT con JWKS

Cambias de lado: ahora escribes la API que recibe access tokens. api-pedidos valida cada token localmente con las claves públicas del realm, exige que vaya dirigido a ella y sabe quién llama sin preguntar a Keycloak.

≈ 75 min Carpeta: tienda/pasos/paso-05 Realm nuevo: hay que reimportar

En este capítulo

Trabajas en
api-pedidos (nueva: valida JWT) y tienda-web, que ahora la llama. Además, tienda-cli (cmd/token).
Carpeta
tienda/pasos/paso-05 · realm nuevo: docker compose down -v en el paso anterior
Archivos
Nuevos: cmd/api/main.go, internal/apiauth/apiauth.go, internal/api/api.go, internal/pedidos/, internal/pedidosclient/, cmd/token/main.go; en Keycloak, la audiencia api-pedidos · ver todos los cambios del paso.
En marcha
Keycloak del paso 05, go run ./cmd/api y go run ./cmd/web (cada uno en su terminal).
Comprueba
go -C tools/comprobar run . -paso 5 -servicios (desde course/)

Al terminar sabrás

  • Registrar una API como client de Keycloak y conseguir que los tokens la incluyan en aud.
  • Escribir un middleware que valide access tokens: firma con JWKS, iss, aud, exp y tipo.
  • Responder bien a los errores de autenticación (RFC 6750) y no filtrar información.
  • Conseguir tokens desde la terminal con el Device Authorization Grant para probar la API.
  • Elegir entre validación local e introspección.

Qué cambia

tienda-web y la herramienta de terminal llaman a api-pedidos con access tokens; api-pedidos solo descarga las claves públicas de Keycloak Navegador tienda-web :3000 Terminal cmd/token + curl api-pedidos :8081 · nueva Keycloak — realm «tienda» clients: tienda-web · tienda-cli · api-pedidos · scope api-pedidos (audiencia) cookie Bearer Bearer (token de tienda-cli) login · tokens device flow JWKS: al arrancar y si aparece un kid nuevo
api-pedidos no habla con Keycloak en cada petición: valida los tokens con las claves públicas que ya tiene en caché.
Pieza nuevaQué es
cmd/apiapi-pedidos, en el puerto 8081.
internal/apiauthEl middleware que valida access tokens.
internal/api, internal/pedidosHandlers y pedidos en memoria, ahora asociados al sub del usuario.
cmd/tokenConsigue un access token desde la terminal (flujo de dispositivo).
internal/pedidosclientLo que usa tienda-web para llamar a la API; los pedidos de mentira desaparecen.

1. Reimportar el realm

El realm cambia mucho, así que lo importamos desde cero. Los proyectos de Compose de todos los pasos se llaman infra (como su carpeta) y comparten volumen: primero se borra el anterior y después se arranca el nuevo.

cd tienda/pasos/paso-04/infra && docker compose down -v    # borra la base de datos
cd ../../paso-05/infra && docker compose up -d               # importa el realm nuevo
Se pierde la configuración manual

La URL de back-channel logout de la lección 4 no está en el JSON (depende de tu IP); vuelve a ponerla si la quieres. Además, el realm genera claves de firma nuevas: cualquier token anterior deja de validar (failed to verify signature).

Novedades del realm:

  • ana y carlos tienen IDs fijos (…0a1 y …0c1), para que los pedidos de ejemplo puedan asociarse a su sub.
  • Client api-pedidos: representa a la API.
  • Client scope api-pedidos: añade la API a la audiencia de los tokens.
  • Client tienda-cli: client público para la terminal.
La trampa de los client scopes al importar

Si un JSON de realm declara "clientScopes", Keycloak no crea los scopes por defecto (profile, email, roles, basic…). Lo comprobamos con Keycloak 26.8: el realm quedó solo con offline_access y el nuestro, y los tokens se quedarían sin sub, roles ni email. Por eso el JSON de este paso es largo (unas 900 líneas): incluye los scopes por defecto exportados de un Keycloak real. Lo genera tools/realm/build_realm.py a partir de una base escrita a mano.

2. La audiencia: «este token es para mí»

En la lección 1 viste que el access token de ana tenía aud: account: Keycloak no sabe qué APIs va a llamar tu app hasta que se lo dices. Si una API no comprobara aud, aceptaría cualquier token del realm, incluido uno emitido para otra API, que podría reenviarlo contra ti.

Se resuelve en dos piezas. Primero, la API se registra como client, sin ningún flujo activado, porque nunca pide tokens: solo los recibe.

{
  "clientId": "api-pedidos",
  "name": "API de pedidos",
  "description": "Resource server: recibe access tokens, no los pide",
  "enabled": true,
  "protocol": "openid-connect",
  "publicClient": false,
  "clientAuthenticatorType": "client-secret",
  "secret": "api-pedidos-secret",
  "standardFlowEnabled": false,
  "implicitFlowEnabled": false,
  "directAccessGrantsEnabled": false,
  "serviceAccountsEnabled": false,
  "defaultClientScopes": [
    "web-origins",
    "acr",
    "profile",
    "roles",
    "basic",
    "email"
  ],
  "optionalClientScopes": [
    "address",
    "phone",
    "organization",
    "offline_access",
    "microprofile-jwt"
  ]
}

Después, un client scope con un Audience mapper que añade api-pedidos al aud del access token:

{
  "name": "api-pedidos",
  "description": "Añade api-pedidos a la audiencia (aud) del access token",
  "protocol": "openid-connect",
  "attributes": {
    "include.in.token.scope": "false",
    "display.on.consent.screen": "false"
  },
  "protocolMappers": [
    {
      "name": "audiencia api-pedidos",
      "protocol": "openid-connect",
      "protocolMapper": "oidc-audience-mapper",
      "consentRequired": false,
      "config": {
        "included.client.audience": "api-pedidos",
        "id.token.claim": "false",
        "access.token.claim": "true",
        "introspection.token.claim": "true"
      }
    }
  ]
}
Detalle del mapper de audiencia en el client scope api-pedidos
Client scopes → api-pedidos → Mappers → audiencia api-pedidos.

Ese scope se asigna como Default a los clients que llaman a la API (tienda-web y tienda-cli), así que va en todos sus tokens sin que tengan que pedirlo:

Pestaña Client scopes de tienda-web con api-pedidos como Default
Clients → tienda-web → Client scopes: api-pedidos como Default.

Resultado, comprobado con un login real de ana:

"aud": ["api-pedidos", "account"],
"sub": "00000000-0000-4000-8000-0000000000a1",
"azp": "tienda-web",
"typ": "Bearer"
Previsualiza tokens sin hacer login

En Clients → tienda-web → Client scopes → Evaluate eliges un usuario y Keycloak te muestra el access token, el ID token y el userinfo que generaría. Es la forma más rápida de comprobar un mapper.

3. El middleware

Validar un access token JWT exige las mismas comprobaciones que un ID token: firma, iss, aud y exp. Por eso reutilizamos el verificador de go-oidc con ClientID: "api-pedidos". Él se encarga del JWKS: lo descarga, lo guarda en caché y, si llega un token con un kid desconocido (Keycloak rotó sus claves), lo vuelve a descargar.

// Package apiauth protege una API con access tokens de Keycloak: valida el
// JWT localmente (firma con el JWKS del realm, iss, aud, exp) y deja en el
// contexto a quién representa el token.
package apiauth

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strings"

	"github.com/coreos/go-oidc/v3/oidc"

	"tienda/internal/jsonhttp"
)

// Principal es quien hace la petición, según el access token verificado.
type Principal struct {
	Subject  string   // sub: el usuario (o la service account de un client)
	Username string   // preferred_username
	ClientID string   // azp: la aplicación que pidió el token
	Scopes   []string // scope, separado por espacios
	Roles    []string // realm_access.roles
}

// Verifier valida access tokens emitidos por un realm para una audiencia.
type Verifier struct {
	verifier *oidc.IDTokenVerifier
}

// NewVerifier lee el descubrimiento del issuer y prepara la validación.
// audience es el client ID de la API: el token debe incluirlo en «aud».
func NewVerifier(ctx context.Context, issuer, audience string) (*Verifier, error) {
	provider, err := oidc.NewProvider(ctx, issuer)
	if err != nil {
		return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", issuer, err)
	}
	// go-oidc se diseñó para ID tokens, pero las comprobaciones son las mismas
	// que necesita un access token JWT: firma (JWKS, con caché y rotación de
	// claves), iss exacto, aud contiene ClientID y exp.
	return &Verifier{verifier: provider.Verifier(&oidc.Config{ClientID: audience})}, nil
}

// 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"`
	}
	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,
	}, nil
}

type principalKey struct{}

// Middleware exige un access token válido en «Authorization: Bearer …» y
// guarda el Principal en el contexto de la petición.
func (v *Verifier) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		raw, ok := bearerToken(r)
		if !ok {
			unauthorized(w, "", "falta la cabecera Authorization: Bearer <access token>")
			return
		}
		p, err := v.Verify(r.Context(), raw)
		if err != nil {
			log.Printf("token rechazado: %v", err)
			var expired *oidc.TokenExpiredError
			if errors.As(err, &expired) {
				unauthorized(w, "invalid_token", "el access token ha caducado")
				return
			}
			unauthorized(w, "invalid_token", "access token inválido")
			return
		}
		ctx := context.WithValue(r.Context(), principalKey{}, p)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// FromContext devuelve el Principal que dejó Middleware.
func FromContext(ctx context.Context) *Principal {
	p, _ := ctx.Value(principalKey{}).(*Principal)
	return p
}

// bearerToken extrae el token de «Authorization: Bearer <token>».
func bearerToken(r *http.Request) (string, bool) {
	scheme, tok, ok := strings.Cut(r.Header.Get("Authorization"), " ")
	if !ok || !strings.EqualFold(scheme, "Bearer") || tok == "" {
		return "", false
	}
	return tok, true
}

// unauthorized responde 401 con la cabecera WWW-Authenticate de RFC 6750.
// Sin token no se indica código de error; con un token malo, invalid_token.
func unauthorized(w http.ResponseWriter, code, description string) {
	h := `Bearer realm="api-pedidos"`
	if code != "" {
		h += fmt.Sprintf(`, error=%q, error_description=%q`, code, description)
	} else {
		code = "unauthorized"
	}
	w.Header().Set("WWW-Authenticate", h)
	jsonhttp.Error(w, http.StatusUnauthorized, code, description)
}
ComprobaciónQuién la haceQué evita
Firma con la clave del kidgo-oidc (JWKS)Tokens fabricados o modificados.
alg permitidogo-oidc (los del descubrimiento)alg: none y confusión de algoritmos.
iss exactogo-oidcTokens de otro realm u otro Keycloak.
aud contiene api-pedidosgo-oidc (ClientID)Tokens emitidos para otra API.
expgo-oidcTokens caducados (o robados hace tiempo).
typ == "Bearer"NosotrosUsar un ID token o un refresh token como access token.

Fíjate en las respuestas: el detalle del fallo va al log, y al cliente solo «access token inválido» o «ha caducado», con la cabecera WWW-Authenticate de RFC 6750 que los clientes OAuth saben interpretar.

4. Los handlers

// Package api contiene los handlers HTTP de api-pedidos.
package api

import (
	"net/http"
	"strconv"

	"tienda/internal/apiauth"
	"tienda/internal/jsonhttp"
	"tienda/internal/pedidos"
)

type handlers struct {
	store *pedidos.Store
}

// Register añade las rutas de la API. Todas exigen un access token válido.
func Register(mux *http.ServeMux, v *apiauth.Verifier, store *pedidos.Store) {
	h := &handlers{store: store}
	mux.Handle("GET /pedidos", v.Middleware(http.HandlerFunc(h.list)))
	mux.Handle("GET /pedidos/{id}", v.Middleware(http.HandlerFunc(h.get)))
}

// list devuelve los pedidos de quien llama, identificado por el «sub» del token.
func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
	p := apiauth.FromContext(r.Context())
	jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.ByOwner(p.Subject)})
}

// get devuelve un pedido si pertenece a quien llama. Si es de otro usuario
// respondemos 404, no 403: así no revelamos qué números de pedido existen.
func (h *handlers) get(w http.ResponseWriter, r *http.Request) {
	p := apiauth.FromContext(r.Context())
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "el id debe ser un número")
		return
	}
	o, ok := h.store.Get(id)
	if !ok || o.Owner != p.Subject {
		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
		return
	}
	jsonhttp.Write(w, http.StatusOK, o)
}
  • Los pedidos se buscan por p.Subject (el sub). El usuario no puede elegir de quién son los pedidos que ve: lo dice el token, que no puede falsificar.
  • Un pedido ajeno devuelve 404, igual que uno inexistente. Con un 403, un atacante podría ir probando números para saber cuáles existen.
Ver internal/pedidos/pedidos.go y cmd/api/main.go
// Package pedidos es el dominio de api-pedidos: los pedidos y un almacén en
// memoria con datos de ejemplo.
package pedidos

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

// IDs fijos de los usuarios del realm (ver infra/realm/tienda-realm.json).
// Los pedidos se asocian al «sub» del token, nunca al nombre de usuario.
const (
	AnaID    = "00000000-0000-4000-8000-0000000000a1"
	CarlosID = "00000000-0000-4000-8000-0000000000c1"
)

// Order es un pedido.
type Order struct {
	ID        int       `json:"id"`
	Owner     string    `json:"owner"` // sub del cliente
	Items     string    `json:"items"`
	Total     float64   `json:"total"`
	Status    string    `json:"status"`
	CreatedAt time.Time `json:"created_at"`
}

// Store guarda los pedidos en memoria, seguro para uso concurrente.
type Store struct {
	mu     sync.Mutex
	orders map[int]Order
}

// NewStore crea un almacén con los pedidos de ejemplo de ana y carlos.
func NewStore() *Store {
	day := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
	s := &Store{orders: make(map[int]Order)}
	for _, o := range []Order{
		{1001, AnaID, "Gopher de peluche ×1, Pegatinas OIDC ×2", 29.88, "Enviado", day},
		{1002, AnaID, "Taza «go fmt» ×1", 9.50, "Pendiente", day.Add(48 * time.Hour)},
		{1003, CarlosID, "Camiseta Keycloak ×2", 30.00, "Entregado", day.Add(24 * time.Hour)},
	} {
		s.orders[o.ID] = o
	}
	return s
}

// ByOwner devuelve los pedidos de un usuario, ordenados por número.
func (s *Store) ByOwner(sub string) []Order {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := []Order{}
	for _, o := range s.orders {
		if o.Owner == sub {
			out = append(out, o)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out
}

// Get devuelve un pedido por su número.
func (s *Store) Get(id int) (Order, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	o, ok := s.orders[id]
	return o, ok
}
// Command api es api-pedidos: la API REST de pedidos, protegida con access
// tokens de Keycloak.
//
// Uso (desde tienda/pasos/paso-05):
//
//	go run ./cmd/api
package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"tienda/internal/api"
	"tienda/internal/apiauth"
	"tienda/internal/pedidos"
)

func main() {
	issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
	audience := env("API_AUDIENCE", "api-pedidos") // client ID de la API en Keycloak
	addr := env("ADDR", ":8081")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	verifier, err := apiauth.NewVerifier(ctx, issuer, audience)
	if err != nil {
		log.Fatal(err) // ¿Keycloak está arrancado?
	}

	mux := http.NewServeMux()
	api.Register(mux, verifier, pedidos.NewStore())

	srv := &http.Server{
		Addr:              addr,
		Handler:           logRequests(mux),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Printf("api-pedidos escuchando en http://%s (issuer %s, audiencia %s)", listenHost(addr), issuer, audience)
	log.Fatal(srv.ListenAndServe())
}

// statusRecorder recuerda el código de estado para el log.
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

// logRequests escribe una línea por petición, con su código de estado.
func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)
		log.Printf("%s %s → %d (%s)", r.Method, r.URL.Path, rec.status, time.Since(start).Round(time.Millisecond))
	})
}

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

// listenHost convierte ":3000" en "localhost:3000" para mostrar la URL.
func listenHost(addr string) string {
	if strings.HasPrefix(addr, ":") {
		return "localhost" + addr
	}
	return addr
}

5. Probar la API desde la terminal

Para llamar a la API con curl necesitas un access token. tienda-web no te lo da (y no debe), así que el realm trae un client público, tienda-cli, con el Device Authorization Grant: el flujo de las teles y las CLIs de la lección 1. Su única capacidad activada es esa:

Capability config de tienda-cli con el Device Authorization Grant
Clients → tienda-cli: público (Client authentication Off) y solo OAuth 2.0 Device Authorization Grant.
// Command token consigue un access token del realm tienda con el flujo de
// dispositivo (Device Authorization Grant) del client público tienda-cli.
// Sirve para probar api-pedidos desde la terminal:
//
//	TOKEN=$(go run ./cmd/token)
//	curl -H "Authorization: Bearer $TOKEN" localhost:8081/pedidos
//
// Las instrucciones van a stderr; por stdout solo sale el token.
package main

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"os"
	"strings"
	"time"

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

func main() {
	issuer := flag.String("issuer", "http://localhost:8080/realms/tienda", "URL del realm")
	clientID := flag.String("client", "tienda-cli", "client público con el Device Authorization Grant activado")
	scopes := flag.String("scope", "", "scopes extra, separados por espacios (p. ej. \"pedidos:escribir\")")
	showClaims := flag.Bool("claims", false, "muestra en stderr los claims del access token")
	flag.Parse()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
	defer cancel()

	provider, err := oidc.NewProvider(ctx, *issuer)
	if err != nil {
		log.Fatal(err)
	}
	cfg := oauth2.Config{
		ClientID: *clientID,
		Endpoint: provider.Endpoint(), // incluye device_authorization_endpoint
		Scopes:   append([]string{oidc.ScopeOpenID}, strings.Fields(*scopes)...),
	}

	// 1. Pedimos un código de dispositivo y otro para el usuario.
	da, err := cfg.DeviceAuth(ctx)
	if err != nil {
		log.Fatalf("device auth: %v", err)
	}
	fmt.Fprintf(os.Stderr, "Abre en el navegador:\n\n  %s\n\ne introduce el código %s, o abre directamente:\n\n  %s\n\nEsperando…\n",
		da.VerificationURI, da.UserCode, da.VerificationURIComplete)

	// 2. Mientras el usuario inicia sesión, x/oauth2 consulta el endpoint de
	//    token cada «interval» segundos (y respeta authorization_pending y slow_down).
	tok, err := cfg.DeviceAccessToken(ctx, da)
	if err != nil {
		log.Fatalf("device token: %v", err)
	}

	if *showClaims {
		printClaims(tok.AccessToken)
	}
	fmt.Println(tok.AccessToken)
}

// printClaims muestra el payload del JWT, sin verificarlo: es solo para mirar.
func printClaims(jwt string) {
	parts := strings.Split(jwt, ".")
	if len(parts) != 3 {
		return
	}
	raw, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		return
	}
	var v map[string]any
	if json.Unmarshal(raw, &v) == nil {
		pretty, _ := json.MarshalIndent(v, "", "  ")
		fmt.Fprintf(os.Stderr, "%s\n", pretty)
	}
}

Arranca la API y pide un token en otra terminal:

cd tienda/pasos/paso-05
go run ./cmd/api
# api-pedidos escuchando en http://localhost:8081 (issuer http://localhost:8080/realms/tienda, audiencia api-pedidos)
cd tienda/pasos/paso-05
TOKEN=$(go run ./cmd/token)
# Abre en el navegador:
#   http://localhost:8080/realms/tienda/device
# e introduce el código XGFJ-YXXY, o abre directamente:
#   http://localhost:8080/realms/tienda/device?user_code=XGFJ-YXXY
# Esperando…
En PowerShell (Windows, probado)
cd tienda/pasos/paso-05
$TOKEN = go run ./cmd/token          # las instrucciones salen en pantalla; el token queda en $TOKEN
curl.exe -s -H "Authorization: Bearer $TOKEN" localhost:8081/pedidos
# En PowerShell 5.1, «curl» a secas es Invoke-WebRequest: usa siempre curl.exe

En el navegador: introduce el código, entra como ana y concede el acceso. La terminal recibe el token sola.

Página Device Login de Keycloak con el código introducido
1. El código que muestra la terminal.
Pantalla de consentimiento Grant Access to Tienda CLI
2. Tras el login, Keycloak pide consentimiento: en el flujo de dispositivo siempre lo hace.

Ahora, las pruebas. Todas son salidas reales:

curl -s -H "Authorization: Bearer $TOKEN" localhost:8081/pedidos
# {"pedidos":[{"id":1001,"owner":"00000000-0000-4000-8000-0000000000a1","items":"Gopher de peluche ×1, Pegatinas OIDC ×2","total":29.88,"status":"Enviado",...},{"id":1002,...}]}

curl -s -H "Authorization: Bearer $TOKEN" localhost:8081/pedidos/1003     # es de carlos
# {"error":"not_found","error_description":"pedido no encontrado"}

curl -si localhost:8081/pedidos                                             # sin token
# HTTP/1.1 401 Unauthorized
# Www-Authenticate: Bearer realm="api-pedidos"

curl -si -H "Authorization: Bearer abc" localhost:8081/pedidos
# HTTP/1.1 401 Unauthorized
# Www-Authenticate: Bearer realm="api-pedidos", error="invalid_token", error_description="access token inválido"

Y lo que la API ha escrito en su log para cada token rechazado durante las pruebas del curso:

token rechazado: oidc: malformed jwt: go-jose/go-jose: compact JWS format must have three parts   ← "abc"
token rechazado: oidc: expected audience "api-pedidos" got ["tienda-web"]                          ← un ID token
token rechazado: failed to verify signature: failed to verify id token signature                 ← token del realm master
token rechazado: failed to verify signature: failed to verify id token signature                 ← sub cambiado a mano
token rechazado: oidc: token is expired (Token Expiry: 2026-10-09 16:30:05 -0300 -03)             ← caducado

(El mensaje dice «id token» porque go-oidc se escribió para ID tokens; la comprobación es la misma).

6. tienda-web llama a la API

«Mis pedidos» deja de usar datos inventados: pide un access token vigente a la sesión (renovándolo si hace falta, como en la lección 4) y llama a la API.

// Package pedidosclient es el cliente HTTP que usa tienda-web para llamar a
// api-pedidos en nombre del usuario, con su access token.
package pedidosclient

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"time"

	"golang.org/x/oauth2"
)

// Order es un pedido tal como lo devuelve api-pedidos.
type Order struct {
	ID        int       `json:"id"`
	Owner     string    `json:"owner"`
	Items     string    `json:"items"`
	Total     float64   `json:"total"`
	Status    string    `json:"status"`
	CreatedAt time.Time `json:"created_at"`
}

// ErrUnauthorized indica que la API rechazó el access token (401).
var ErrUnauthorized = errors.New("api-pedidos rechazó el access token")

// Client llama a api-pedidos.
type Client struct {
	baseURL string
	http    *http.Client
}

// New crea un cliente para la API en baseURL (p. ej. http://localhost:8081).
func New(baseURL string) *Client {
	return &Client{baseURL: baseURL, http: &http.Client{Timeout: 5 * time.Second}}
}

// MyOrders devuelve los pedidos del usuario dueño del token.
func (c *Client) MyOrders(ctx context.Context, tok *oauth2.Token) ([]Order, error) {
	var out struct {
		Pedidos []Order `json:"pedidos"`
	}
	err := c.do(ctx, tok, http.MethodGet, "/pedidos", &out)
	return out.Pedidos, err
}

// do hace la petición con «Authorization: Bearer <access token>» y decodifica
// la respuesta JSON en out.
func (c *Client) do(ctx context.Context, tok *oauth2.Token, method, path string, out any) error {
	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
	if err != nil {
		return err
	}
	tok.SetAuthHeader(req) // añade la cabecera Authorization: Bearer …
	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusUnauthorized {
		return fmt.Errorf("%w: %s", ErrUnauthorized, resp.Header.Get("WWW-Authenticate"))
	}
	if resp.StatusCode >= 400 {
		var e struct {
			Code        string `json:"error"`
			Description string `json:"error_description"`
		}
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		_ = json.Unmarshal(body, &e)
		return fmt.Errorf("api-pedidos respondió %s: %s", resp.Status, e.Description)
	}
	return json.NewDecoder(resp.Body).Decode(out)
}
// pedidos pide a api-pedidos los pedidos del usuario, con su access token.
func (h *handlers) pedidos(w http.ResponseWriter, r *http.Request) {
	sess, _ := h.auth.CurrentSession(r)
	tok, err := h.auth.Token(r.Context(), sess, false) // renueva si hace falta
	if errors.Is(err, auth.ErrSessionEnded) {
		http.Redirect(w, r, auth.LoginURL(r), http.StatusFound)
		return
	}
	data := pageData{Active: "pedidos", Session: sess}
	if err != nil {
		data.Error = err.Error()
	} else if orders, err := h.api.MyOrders(r.Context(), tok); err != nil {
		log.Printf("api-pedidos: %v", err)
		data.Error = "No se pudieron cargar los pedidos: " + err.Error()
	} else {
		data.Orders = orders
	}
	h.render(w, "pedidos.html", data)
}
cd tienda/pasos/paso-05
go run ./cmd/web      # API_URL=http://localhost:8081 por defecto

Entra como carlos: «Mis pedidos» muestra solo el #1003, servido por api-pedidos, y en el log de la API aparece GET /pedidos → 200.

7. Validación local frente a introspección

Hay otra forma de validar un token: preguntarle a Keycloak en cada petición (introspección, RFC 7662). La API se autentica como client confidencial y Keycloak responde si el token está activo. La diferencia se ve muy bien con un experimento real, hecho con un token de ana:

curl -s -u api-pedidos:api-pedidos-secret -d "token=$TOKEN" \
  http://localhost:8080/realms/tienda/protocol/openid-connect/token/introspect
# {"active":true,"sub":"00000000-0000-4000-8000-0000000000a1","aud":["api-pedidos","account"],...}

# Un administrador cierra las sesiones de ana… y repetimos:
# {"active":false}

curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" localhost:8081/pedidos
# 200      ← la validación local no se entera hasta que el token caduque
Validación local (JWKS)Introspección
Coste por peticiónUna verificación de firma, en memoriaUna llamada HTTP a Keycloak
Si Keycloak caeLa API sigue funcionandoLa API deja de funcionar
RevocaciónSe nota cuando el token caduca (≤ 5 min)Inmediata
Tokens opacos (no JWT)No sirveSí

Lo habitual es validar localmente con access tokens de vida corta y reservar la introspección para operaciones delicadas (ejercicio 3).

Ejercicios

1. Sin audiencia · fácil

En Clients → tienda-cli → Client scopes, quita el scope api-pedidos. Pide un token nuevo con go run ./cmd/token -claims y llama a la API. ¿Qué ves en el token, en la respuesta y en el log? Después, vuelve a añadirlo como Default.

Ver solución

El token ya no lleva api-pedidos en aud (solo account). La API responde 401 invalid_token y en su log aparece oidc: expected audience "api-pedidos" got ["account"]. Es exactamente la protección que queríamos: un token válido del realm, pero no emitido para esta API.

2. Lista blanca de aplicaciones · fácil

Haz que la API solo acepte tokens pedidos por tienda-web o tienda-cli, aunque otro client del realm consiga incluir la audiencia.

Ver solución

El claim azp dice qué client pidió el token. En Verify, tras comprobar typ (añade "slices" a los imports):

var allowedClients = []string{"tienda-web", "tienda-cli"}

// en Verify, después de la comprobación de typ:
// Solo aceptamos tokens pedidos por nuestras aplicaciones.
if !slices.Contains(allowedClients, c.AZP) {
	return nil, fmt.Errorf("client no autorizado (azp=%q)", c.AZP)
}

En un proyecto real, esa lista vendría de la configuración.

3. Introspección para lo delicado · media

Escribe un Introspector que pregunte a Keycloak si un token sigue activo. La API usará el client api-pedidos y su secreto (api-pedidos-secret). Úsalo solo en GET /pedidos/{id}.

Ver solución
package apiauth

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strings"
)

// Introspector pregunta a Keycloak si un token sigue activo (RFC 7662).
// La API se autentica como client confidencial (api-pedidos + su secreto).
type Introspector struct {
	URL, ClientID, ClientSecret string
}

// Active devuelve true si Keycloak considera el token vigente ahora mismo.
func (in *Introspector) Active(ctx context.Context, raw string) (bool, error) {
	form := url.Values{"token": {raw}, "token_type_hint": {"access_token"}}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, in.URL, strings.NewReader(form.Encode()))
	if err != nil {
		return false, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.SetBasicAuth(in.ClientID, in.ClientSecret)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return false, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return false, fmt.Errorf("introspección: %s", resp.Status)
	}
	var r struct {
		Active bool `json:"active"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return false, err
	}
	return r.Active, nil
}

La URL es el introspection_endpoint del descubrimiento (…/protocol/openid-connect/token/introspect). Lo hemos probado contra Keycloak 26.8: true con un token de ana recién emitido y false con uno inventado. Con un secreto incorrecto, Keycloak responde {"error":"invalid_client","error_description":"Client authentication failed."}. Para usarlo en get necesitas el token en bruto: guárdalo también en el contexto desde el middleware.

4. Evaluate · fácil

Sin hacer login, averigua qué aud y qué roles tendría un access token de carlos para tienda-cli.

Ver solución

Clients → tienda-cli → Client scopes → Evaluate, elige User carlos y abre Generated access token: aud es ["api-pedidos", "account"] y realm_access.roles incluye cliente y admin.

Errores comunes

expected audience oidc: expected audience "api-pedidos" got ["account"]

El client que pidió el token no tiene el scope de audiencia, o el scope está como Optional y no se pidió. Arreglo: asígnalo como Default al client (ejercicio 1). Ojo: no se arregla quitando la comprobación de aud.

failed to verify signature Tokens que antes funcionaban

Tras un docker compose down -v, el realm se crea de nuevo con claves nuevas: los tokens anteriores ya no validan. También pasa con tokens de otro realm u otro Keycloak, o si alguien ha tocado el payload. Arreglo: pide un token nuevo.

malformed jwt «compact JWS format must have three parts»

Lo que llega no es un JWT. Suele ser un fallo al copiar: comillas, un salto de línea o el prefijo repetido (Bearer Bearer …). Arreglo: TOKEN=$(go run ./cmd/token) y -H "Authorization: Bearer $TOKEN".

token is expired A los 5 minutos todo falla

Los access tokens duran 5 minutos. Con curl, pide otro. En tienda-web no pasa, porque renueva con el refresh token antes de llamar.

id token issued by a different provider La API en Docker

Si metes la API en un contenedor y la configuras con http://keycloak:8080/realms/tienda, rechazará los tokens emitidos vía localhost:8080: otro iss. Arreglo: un único hostname público para Keycloak (--hostname), igual para todos.

sin «sub» Tras importar un realm propio, los tokens vienen casi vacíos

Declaraste clientScopes en el JSON sin incluir los de por defecto (apartado 1). Los tokens no llevan sub, preferred_username, email ni roles. Arreglo: incluye los scopes por defecto (como hace build_realm.py) o crea los tuyos con la Admin API después de importar.

authorization_pending / slow_down en el flujo de dispositivo

No son errores: el usuario aún no ha aprobado, o la CLI consulta demasiado rápido. DeviceAccessToken los gestiona sola, esperando el interval que indica Keycloak (5 s). Si lo programas a mano, respétalos.

Resumen

Una API protegida con Keycloak es un client sin flujos que aparece en el aud de los tokens gracias a un mapper de audiencia. El middleware valida localmente firma, iss, aud, exp y typ, y saca del token quién llama (sub). Todavía no distingue entre ana y carlos más allá de sus pedidos: en la lección 6 añadimos roles y scopes.