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.
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; cambiainternal/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(desdecourse/)
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ón | Problema |
|---|---|
| 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 Exchange | Keycloak 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. |
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:
facturacionpuede intercambiar. Es confidencial y tiene marcada Standard Token Exchange (atributostandard.token.exchange.enabled).- El token de ana va dirigido a
facturacion. Keycloak solo deja intercambiar un token a un client que esté en suaud. Un client scope nuevo,facturacion(con un mapper de audiencia, como el de la lección 5), se asigna como Default atienda-weby como Optional atienda-cli, para poder probar desde la terminal. facturacionpuede emitir paraapi-pedidos. La audiencia del token nuevo sale de los client scopes defacturacion: ya tieneapi-pedidoscomo Default desde la lección 7. El parámetroaudiencesolo 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"
]
}
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:
| Claim | Token de ana (de tienda-web) | Token intercambiado |
|---|---|---|
sub | …0000000000a1 (ana) | …0000000000a1 (ana): igual |
sid | xaq1r1kT51A2YV6yjDvmcxDv | el mismo: misma sesión SSO |
azp | tienda-web | facturacion |
aud | ["api-pedidos", "facturacion", "account"] | "api-pedidos" |
realm_access.roles | cliente, … | cliente, …: los de ana |
scope | openid profile email | profile 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).
factura F-0002 solicitada por ana para el pedido #1001
POST /facturas → 201
Y probando el endpoint directamente con el token de ana:
| Petición | Resultado real | Quién lo decide |
|---|---|---|
POST /facturas {"pedido":1001} | 201 F-0002 | — |
| la misma otra vez | 200 la misma F-0002 | facturacion (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 |
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ón | Respuesta 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 caducado | invalid_request «Invalid token» |
| Sesión del usuario cerrada | invalid_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.
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.