Módulo 4 · Lección 08

Token Exchange

ana pide desde la tienda una factura anticipada. facturacion necesita preguntar a api-pedidos si ese pedido es de ana, y lo hace como ana: cambia el token que recibió por uno nuevo, emitido para la API, que sigue representando a ana.

≈ 75 min Carpeta: tienda/pasos/paso-08 RFC 8693 Realm nuevo: hay que reimportar

En este capítulo

Trabajas en
facturacion: actuar en nombre del usuario (Token Exchange). tienda-web: el botón «Pedir factura».
Carpeta
tienda/pasos/paso-08 · realm nuevo
Archivos
Nuevos: internal/tokenexchange/exchange.go, internal/facturacion/server.go, internal/facturasclient/client.go; cambia internal/web/compras.go · ver todos los cambios del paso.
En marcha
Keycloak del paso 08, api, web y facturacion.
Comprueba
go -C tools/comprobar run . -paso 8 -servicios (desde course/)

Al terminar sabrás

  • Por qué un servicio intermedio no debería reenviar el token del usuario ni usar su propia service account para todo.
  • Configurar el standard token exchange de Keycloak y qué comprueba.
  • Implementar el intercambio en Go (x/oauth2 no lo incluye) y usar el token resultante.
  • Leer un token intercambiado: qué conserva (sub, sesión, roles) y qué cambia (azp, aud).

1. El problema del servicio intermedio

En la lección 7, facturacion trabajaba solo. Ahora la llama tienda-web en nombre de ana, con POST /facturas {"pedido": 1001}. Antes de facturar, tiene que saber si el pedido 1001 es de ana y en qué estado está. Hay tres maneras de preguntárselo a api-pedidos:

OpciónProblema
Reenviar a la API el token que mandó ana (token passthrough)Ese token se emitió para que tienda-web llamara a varios servicios: cualquiera que lo reciba puede usarlo en todos ellos. Y la API cree que la llama tienda-web (azp), no facturacion.
Usar su service account (rol facturar)La service account ve los pedidos de todos. facturacion tendría que reimplementar «un cliente solo ve lo suyo»; un fallo ahí y ana obtiene facturas de pedidos ajenos (el problema del confused deputy).
Token ExchangeKeycloak emite un token nuevo: sigue siendo ana (sub), lo pide facturacion (azp) y solo vale para api-pedidos (aud). La API aplica sus reglas de siempre.
Secuencia de token exchange: tienda-web llama a facturacion con el token de ana; facturacion lo cambia en Keycloak por uno para api-pedidos y consulta el pedido tienda-web facturacion Keycloak api-pedidos 1 POST /facturas {pedido:1001} Bearer: token de ana (aud facturacion…) 2 valida: aud facturacion, rol cliente 3 POST /token token-exchange subject_token=ana · audience=api-pedidos 4 token nuevo: sub=ana azp=facturacion · aud=api-pedidos 5 GET /pedidos/1001 Bearer: token nuevo 6 200 si es de ana · 404 si no 7 201 · factura F-0002
Naranja: el intercambio (pasos 3–4) es servidor a servidor, autenticado con el secreto de facturacion.

2. Configuración en Keycloak

cd tienda/pasos/paso-07/infra && docker compose down -v
cd ../../paso-08/infra && docker compose up -d

Keycloak 26 trae el standard token exchange (versión 2, la del RFC 8693) activado como funcionalidad, pero cada client que quiera intercambiar tiene que pedirlo. Hacen falta tres cosas:

  1. facturacion puede intercambiar. Es confidencial y tiene marcada Standard Token Exchange (atributo standard.token.exchange.enabled).
  2. El token de ana va dirigido a facturacion. Keycloak solo deja intercambiar un token a un client que esté en su aud. Un client scope nuevo, facturacion (con un mapper de audiencia, como el de la lección 5), se asigna como Default a tienda-web y como Optional a tienda-cli, para poder probar desde la terminal.
  3. facturacion puede emitir para api-pedidos. La audiencia del token nuevo sale de los client scopes de facturacion: ya tiene api-pedidos como Default desde la lección 7. El parámetro audience solo filtra esa lista; no añade audiencias nuevas.
{
  "clientId": "facturacion",
  "name": "Servicio de facturación",
  "description": "Servicio interno: Client Credentials (lección 7) y Token Exchange (lección 8)",
  "enabled": true,
  "protocol": "openid-connect",
  "publicClient": false,
  "clientAuthenticatorType": "client-secret",
  "secret": "facturacion-secret",
  "standardFlowEnabled": false,
  "implicitFlowEnabled": false,
  "directAccessGrantsEnabled": false,
  "serviceAccountsEnabled": true,
  "attributes": {
    "standard.token.exchange.enabled": "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 y Standard Token Exchange
Clients → facturacion: Service account roles (lección 7) y Standard Token Exchange (esta lección).

3. Un intercambio a mano

Pide un token de ana con la CLI de la lección 5, incluyendo el scope opcional facturacion para que el token vaya dirigido también a ese servicio, y cámbialo:

TOKEN=$(go run ./cmd/token -scope facturacion)    # aud: api-pedidos, facturacion, account

curl -s -u facturacion:facturacion-secret \
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  --data-urlencode subject_token=$TOKEN \
  --data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d audience=api-pedidos \
  http://localhost:8080/realms/tienda/protocol/openid-connect/token
# {"access_token":"eyJ…","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer",
#  "session_state":"xaq1r1kT51A2YV6yjDvmcxDv","scope":"profile email",
#  "issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}
En PowerShell (Windows, probado en 5.1 y 7)
$TOKEN = go run ./cmd/token -scope facturacion

curl.exe -s -u facturacion:facturacion-secret `
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange `
  --data-urlencode "subject_token=$TOKEN" `
  --data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token `
  -d audience=api-pedidos `
  http://localhost:8080/realms/tienda/protocol/openid-connect/token

Comparación real del token de entrada y el de salida:

ClaimToken de ana (de tienda-web)Token intercambiado
sub…0000000000a1 (ana)…0000000000a1 (ana): igual
sidxaq1r1kT51A2YV6yjDvmcxDvel mismo: misma sesión SSO
azptienda-webfacturacion
aud["api-pedidos", "facturacion", "account"]"api-pedidos"
realm_access.rolescliente, …cliente, …: los de ana
scopeopenid profile emailprofile email (los Default de facturacion)

Sin el parámetro audience, el token sale con aud: ["api-pedidos", "account"]: todas las audiencias que facturacion puede emitir. Con él, solo la que necesitamos. Keycloak 26.8 no añade un claim act («quién actúa») en este modo: el rastro de que el token pasó por facturacion es su azp.

4. El intercambio en Go

x/oauth2 no implementa el RFC 8693, pero es solo un POST al endpoint de token. El resultado se devuelve como *oauth2.Token, para usarlo igual que cualquier otro (SetAuthHeader).

// Package tokenexchange implementa el Token Exchange de OAuth 2.0 (RFC 8693)
// tal como lo soporta Keycloak («standard token exchange»): un client
// confidencial cambia el access token de un usuario por otro, emitido a su
// nombre y dirigido a otra audiencia.
package tokenexchange

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

	"golang.org/x/oauth2"
)

const (
	grantType       = "urn:ietf:params:oauth:grant-type:token-exchange"
	accessTokenType = "urn:ietf:params:oauth:token-type:access_token"
)

// Exchanger hace token exchange en nombre de un client confidencial.
type Exchanger struct {
	TokenURL     string // endpoint de token del realm
	ClientID     string // el client que intercambia (debe estar en el aud del token original)
	ClientSecret string
	HTTP         *http.Client
}

// Error es un error OAuth devuelto por Keycloak («error», «error_description»).
type Error struct {
	Code        string `json:"error"`
	Description string `json:"error_description"`
}

func (e *Error) Error() string { return fmt.Sprintf("token exchange: %s: %s", e.Code, e.Description) }

// Exchange cambia subjectToken (el access token del usuario) por un access
// token para audience. El token nuevo conserva el usuario (sub) y su sesión,
// pero su azp es este client y su aud queda reducida a audience.
func (e *Exchanger) Exchange(ctx context.Context, subjectToken, audience string) (*oauth2.Token, error) {
	form := url.Values{
		"grant_type":           {grantType},
		"subject_token":        {subjectToken},
		"subject_token_type":   {accessTokenType},
		"requested_token_type": {accessTokenType},
		"audience":             {audience},
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.TokenURL, strings.NewReader(form.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.SetBasicAuth(url.QueryEscape(e.ClientID), url.QueryEscape(e.ClientSecret)) // RFC 6749 §2.3.1

	client := e.HTTP
	if client == nil {
		client = http.DefaultClient
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		oe := &Error{}
		if json.NewDecoder(resp.Body).Decode(oe) != nil || oe.Code == "" {
			return nil, fmt.Errorf("token exchange: Keycloak respondió %s", resp.Status)
		}
		return nil, oe
	}
	var body struct {
		AccessToken string `json:"access_token"`
		TokenType   string `json:"token_type"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
		return nil, err
	}
	return &oauth2.Token{
		AccessToken: body.AccessToken,
		TokenType:   body.TokenType,
		Expiry:      time.Now().Add(time.Duration(body.ExpiresIn) * time.Second),
	}, nil
}

Para poder intercambiarlo, facturacion necesita el token original. El middleware de apiauth ahora lo guarda en el Principal:

type Principal struct {
	// ... campos existentes ...
	Token string // el access token en bruto, por si hay que intercambiarlo (lección 8)
}
// en Verify: Token: raw,

Y el handler: valida, intercambia, pregunta a la API como ana y factura. Fíjate en que facturacion no comprueba de quién es el pedido: si no es de ana, la API responde 404 y nosotros también.

// request emite una factura anticipada de un pedido del usuario. Para saber
// si el pedido es suyo y en qué estado está, pregunta a api-pedidos EN NOMBRE
// DEL USUARIO: cambia su token por uno para api-pedidos (token exchange), así
// es la API quien aplica sus reglas (un cliente solo ve sus pedidos).
func (s *Server) request(w http.ResponseWriter, r *http.Request) {
	p := apiauth.FromContext(r.Context())
	var body struct {
		Pedido int `json:"pedido"`
	}
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil || body.Pedido <= 0 {
		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", `se espera {"pedido": <número>}`)
		return
	}

	// 1. Token exchange: el token de ana para facturacion → uno de ana para api-pedidos.
	tok, err := s.Exchanger.Exchange(r.Context(), p.Token, "api-pedidos")
	if err != nil {
		log.Printf("intercambio fallido: %v", err)
		var oe *tokenexchange.Error
		if errors.As(err, &oe) && oe.Description == "Invalid token" {
			jsonhttp.Error(w, http.StatusUnauthorized, "invalid_token", "la sesión del usuario ya no es válida")
			return
		}
		jsonhttp.Error(w, http.StatusBadGateway, "exchange_failed", "no se pudo actuar en nombre del usuario")
		return
	}

	// 2. Con ese token, api-pedidos nos dice si el pedido es de ana.
	order, status, err := s.getOrder(r.Context(), tok, body.Pedido)
	switch {
	case err != nil:
		log.Printf("api-pedidos: %v", err)
		jsonhttp.Error(w, http.StatusBadGateway, "api_error", "api-pedidos no respondió")
		return
	case status == http.StatusNotFound:
		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
		return
	case status != http.StatusOK:
		jsonhttp.Error(w, http.StatusBadGateway, "api_error", fmt.Sprintf("api-pedidos respondió %d", status))
		return
	}
	if order.Status != "Enviado" && order.Status != "Entregado" {
		jsonhttp.Error(w, http.StatusConflict, "invalid_state", "solo se facturan pedidos enviados o entregados")
		return
	}

	// 3. Emitimos la factura (o devolvemos la que ya tuviera).
	inv, created := s.Store.Issue(order.ID, p.Subject, order.Total, "solicitada")
	code := http.StatusOK
	if created {
		code = http.StatusCreated
		log.Printf("factura %s solicitada por %s para el pedido #%d", inv.Number, p.Username, order.ID)
	}
	jsonhttp.Write(w, code, inv)
}
Ver internal/facturacion/server.go y cmd/facturacion/main.go completos
package facturacion

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strconv"

	"golang.org/x/oauth2"

	"tienda/internal/apiauth"
	"tienda/internal/jsonhttp"
	"tienda/internal/tokenexchange"
)

// Server expone la facturación a los usuarios (a través de tienda-web).
type Server struct {
	Store     *Store
	Exchanger *tokenexchange.Exchanger
	API       string       // URL base de api-pedidos
	HTTP      *http.Client // cliente sin token: el token lo ponemos a mano
}

// Register añade las rutas. Exigen un access token de usuario cuyo aud
// incluya «facturacion» (lo valida v) y el rol cliente o admin.
func (s *Server) Register(mux *http.ServeMux, v *apiauth.Verifier) {
	usuario := apiauth.RequireRole("cliente", "admin")
	mux.Handle("GET /facturas", v.Middleware(usuario(http.HandlerFunc(s.list))))
	mux.Handle("POST /facturas", v.Middleware(usuario(http.HandlerFunc(s.request))))
}

// list devuelve las facturas de quien llama. No necesita a api-pedidos: las
// facturas son datos de este servicio y el «sub» del token dice de quién son.
func (s *Server) list(w http.ResponseWriter, r *http.Request) {
	p := apiauth.FromContext(r.Context())
	jsonhttp.Write(w, http.StatusOK, map[string]any{"facturas": s.Store.ByCustomer(p.Subject)})
}

// request emite una factura anticipada de un pedido del usuario. Para saber
// si el pedido es suyo y en qué estado está, pregunta a api-pedidos EN NOMBRE
// DEL USUARIO: cambia su token por uno para api-pedidos (token exchange), así
// es la API quien aplica sus reglas (un cliente solo ve sus pedidos).
func (s *Server) request(w http.ResponseWriter, r *http.Request) {
	p := apiauth.FromContext(r.Context())
	var body struct {
		Pedido int `json:"pedido"`
	}
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil || body.Pedido <= 0 {
		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", `se espera {"pedido": <número>}`)
		return
	}

	// 1. Token exchange: el token de ana para facturacion → uno de ana para api-pedidos.
	tok, err := s.Exchanger.Exchange(r.Context(), p.Token, "api-pedidos")
	if err != nil {
		log.Printf("intercambio fallido: %v", err)
		var oe *tokenexchange.Error
		if errors.As(err, &oe) && oe.Description == "Invalid token" {
			jsonhttp.Error(w, http.StatusUnauthorized, "invalid_token", "la sesión del usuario ya no es válida")
			return
		}
		jsonhttp.Error(w, http.StatusBadGateway, "exchange_failed", "no se pudo actuar en nombre del usuario")
		return
	}

	// 2. Con ese token, api-pedidos nos dice si el pedido es de ana.
	order, status, err := s.getOrder(r.Context(), tok, body.Pedido)
	switch {
	case err != nil:
		log.Printf("api-pedidos: %v", err)
		jsonhttp.Error(w, http.StatusBadGateway, "api_error", "api-pedidos no respondió")
		return
	case status == http.StatusNotFound:
		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
		return
	case status != http.StatusOK:
		jsonhttp.Error(w, http.StatusBadGateway, "api_error", fmt.Sprintf("api-pedidos respondió %d", status))
		return
	}
	if order.Status != "Enviado" && order.Status != "Entregado" {
		jsonhttp.Error(w, http.StatusConflict, "invalid_state", "solo se facturan pedidos enviados o entregados")
		return
	}

	// 3. Emitimos la factura (o devolvemos la que ya tuviera).
	inv, created := s.Store.Issue(order.ID, p.Subject, order.Total, "solicitada")
	code := http.StatusOK
	if created {
		code = http.StatusCreated
		log.Printf("factura %s solicitada por %s para el pedido #%d", inv.Number, p.Username, order.ID)
	}
	jsonhttp.Write(w, code, inv)
}

// getOrder pide un pedido a api-pedidos con el token intercambiado.
func (s *Server) getOrder(ctx context.Context, tok *oauth2.Token, id int) (Order, int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.API+"/pedidos/"+strconv.Itoa(id), nil)
	if err != nil {
		return Order{}, 0, err
	}
	tok.SetAuthHeader(req)
	resp, err := s.HTTP.Do(req)
	if err != nil {
		return Order{}, 0, err
	}
	defer resp.Body.Close()
	var o Order
	if resp.StatusCode == http.StatusOK {
		err = json.NewDecoder(resp.Body).Decode(&o)
	}
	return o, resp.StatusCode, err
}
// Command facturacion es el servicio de facturación. Hace dos cosas:
//
//   - Sin usuario: factura cada 30 s los pedidos entregados, con su propio
//     token de Client Credentials (lección 7).
//   - En nombre de un usuario: atiende peticiones de tienda-web en el puerto
//     8082 y, para consultar api-pedidos como ese usuario, intercambia su
//     token (Token Exchange, lección 8).
//
// Uso (desde tienda/pasos/paso-08):
//
//	go run ./cmd/facturacion
package main

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

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

	"tienda/internal/apiauth"
	"tienda/internal/facturacion"
	"tienda/internal/tokenexchange"
)

func main() {
	issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
	clientID := env("OIDC_CLIENT_ID", "facturacion")
	clientSecret := env("OIDC_CLIENT_SECRET", "facturacion-secret") // solo para desarrollo
	apiURL := env("API_URL", "http://localhost:8081")
	addr := env("ADDR", ":8082")
	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)
	}
	tokenURL := provider.Endpoint().TokenURL
	store := facturacion.NewStore()

	// --- Lección 7: Client Credentials para el proceso automático ---
	cc := clientcredentials.Config{ClientID: clientID, ClientSecret: clientSecret, TokenURL: tokenURL}
	serviceClient := oauth2.NewClient(ctx, oauth2.ReuseTokenSource(nil, loggingSource{cc.TokenSource(ctx)}))
	serviceClient.Timeout = 5 * time.Second
	worker := &facturacion.Worker{API: apiURL, HTTP: serviceClient, Store: store, Interval: interval}
	go worker.Run(ctx)

	// --- Lección 8: HTTP para usuarios + Token Exchange ---
	// Valida los tokens que trae tienda-web: su aud debe incluir «facturacion».
	verifier, err := apiauth.NewVerifier(ctx, issuer, clientID)
	if err != nil {
		log.Fatal(err)
	}
	httpClient := &http.Client{Timeout: 5 * time.Second}
	srv := &facturacion.Server{
		Store: store,
		Exchanger: &tokenexchange.Exchanger{
			TokenURL: tokenURL, ClientID: clientID, ClientSecret: clientSecret, HTTP: httpClient,
		},
		API:  apiURL,
		HTTP: httpClient,
	}
	mux := http.NewServeMux()
	srv.Register(mux, verifier)

	server := &http.Server{Addr: addr, Handler: logRequests(mux), ReadHeaderTimeout: 5 * time.Second}
	go func() {
		<-ctx.Done()
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = server.Shutdown(shutdownCtx)
	}()
	log.Printf("facturacion escuchando en http://%s; facturando cada %s contra %s", listenHost(addr), interval, apiURL)
	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

// loggingSource registra cada vez que hace falta un token de servicio nuevo.
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
}

// 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)
}

func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)
		log.Printf("%s %s → %d", r.Method, r.URL.Path, rec.status)
	})
}

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
}

facturacion es ahora a la vez resource server (valida tokens con aud: facturacion, reutilizando apiauth) y client (Client Credentials para el proceso automático, Token Exchange para las peticiones de usuarios).

5. Probarlo

go run ./cmd/api            # :8081
go run ./cmd/facturacion    # :8082
go run ./cmd/web            # :3000  (FACTURACION_URL=http://localhost:8082 por defecto)

Entra como ana y abre Mis pedidos: hay una columna Factura. El #1001 está Enviado y todavía no tiene factura (el proceso automático solo factura los Entregado).

Mis pedidos de ana con el botón Pedir factura
Antes: «Pedir factura» en el pedido enviado.
Mis pedidos de ana con la factura F-0002 solicitada
Después: F-0002 (solicitada). La F-0001 es la automática del pedido de carlos.
factura F-0002 solicitada por ana para el pedido #1001
POST /facturas → 201

Y probando el endpoint directamente con el token de ana:

PeticiónResultado realQuién lo decide
POST /facturas {"pedido":1001}201 F-0002—
la misma otra vez200 la misma F-0002facturacion (no duplica)
{"pedido":1002} (Pendiente)409 «solo se facturan pedidos enviados o entregados»facturacion, con el estado que da la API
{"pedido":1003} (de carlos)404 «pedido no encontrado»api-pedidos, con el token intercambiado
tras cerrar la sesión de ana (token aún vigente)401 «la sesión del usuario ya no es válida»Keycloak, al intercambiar
Un efecto secundario útil

El intercambio pasa por Keycloak, y Keycloak sí sabe si la sesión de ana sigue viva. Así que, a diferencia de la validación local de la lección 5, un servicio que intercambia tokens se entera al momento de que el usuario ha cerrado sesión.

6. Lo que comprueba Keycloak

Todos estos rechazos se han provocado de verdad contra Keycloak 26.8:

SituaciónRespuesta de Keycloak
El client que intercambia no tiene activado el intercambio (tienda-web)invalid_request «Standard token exchange is not enabled for the requested client»
El token no incluye al client en su aud (uno de tienda-cli pedido sin -scope facturacion)access_denied «Client is not within the token audience»
audience que el client no puede emitir (tienda-cli)invalid_request «Requested audience not available: tienda-cli»
Token caducadoinvalid_request «Invalid token»
Sesión del usuario cerradainvalid_request «Invalid token»

Ejercicios

1. Token passthrough · media

Cambia request para llamar a la API con p.Token (el token original de ana) en lugar del intercambiado. ¿Funciona? ¿Qué ve la API como azp? ¿Por qué no es buena idea aunque funcione?

Ver solución

Funciona, porque el token de ana incluye api-pedidos en aud (tienda-web también llama a la API). Pero la API ve azp: tienda-web: no puede saber que la petición llega a través de facturacion. Además, facturacion no podría llamar a una API que no estuviera en el token original, y un token con muchas audiencias es más valioso para quien lo robe. Lo sano es que cada token valga solo para quien lo va a recibir y que cada salto entre servicios se registre en Keycloak.

2. Sin filtrar la audiencia · fácil

Quita el parámetro audience de Exchange. ¿Qué aud tiene ahora el token? ¿Sigue funcionando la factura?

Ver solución

Probado: aud: ["api-pedidos", "account"], todas las audiencias de los client scopes de facturacion. Sigue funcionando, pero el token vale para más destinos de los necesarios. Pedir siempre la audiencia mínima es downscoping.

3. ¿Y con la service account? · media

Implementa mentalmente (o de verdad) request usando el cliente de Client Credentials del worker y GET /facturacion/pedidos. ¿Qué tendrías que comprobar tú a mano? ¿Qué pasaría si se te olvidara?

Ver solución

La service account ve los pedidos de todos los clientes, así que tendrías que buscar el pedido y comprobar order.Owner == p.Subject antes de facturar. Si se te olvida, ana puede pedir la factura del pedido de carlos y obtenerla: facturacion usaría sus permisos amplios en nombre de alguien que no los tiene. Es el confused deputy. Con el token intercambiado, el límite lo pone la API y no depende de que no se te olvide.

Errores comunes

Client is not within the token audience

El token que intentas intercambiar no se emitió para tu servicio. Arreglo: asigna al client que emite el token del usuario (tienda-web) un scope con un mapper de audiencia hacia el que intercambia (facturacion).

Standard token exchange is not enabled

Falta marcar Standard Token Exchange en el client que hace la petición, o es un client público. Arreglo: Capability config del client confidencial.

Requested audience not available

audience solo filtra las audiencias que el client ya puede emitir por sus client scopes. Arreglo: asigna al client que intercambia el scope de audiencia del destino (aquí, api-pedidos).

Invalid token El intercambio falla con un token que parecía bueno

El token caducó o la sesión de su usuario ya no existe. No es un fallo del servicio: trátalo como un 401 para que el cliente vuelva a autenticarse, como hace request.

expected audience "facturacion" 401 al llamar a facturacion

El token que llega no incluye facturacion en aud (en el log: oidc: expected audience "facturacion" got ["api-pedidos" "account"]). Con tienda-web pasa si no se reimportó el realm del paso 8 o si ana no ha vuelto a iniciar sesión desde entonces.

token exchange (v1) Guías antiguas con «permisos» y requested_subject

Muchas guías describen el token exchange «legacy» (v1, una funcionalidad en vista previa con permisos de autorización y suplantación). En Keycloak 26 el estándar es el v2 que usamos aquí, activado por defecto y configurado con una casilla por client.

Resumen del módulo 4

Sin usuario, un servicio usa Client Credentials y los permisos de su service account. En nombre de un usuario, intercambia su token (RFC 8693) por uno nuevo para la API de destino: mismo sub y misma sesión, azp el servicio, aud reducida. Así cada API aplica sus propias reglas al usuario real, y Keycloak controla quién puede actuar en nombre de quién.