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.
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 -ven 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 audienciaapi-pedidos· ver todos los cambios del paso. - En marcha
- Keycloak del paso 05,
go run ./cmd/apiygo run ./cmd/web(cada uno en su terminal). - Comprueba
go -C tools/comprobar run . -paso 5 -servicios(desdecourse/)
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,expy 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
api-pedidos no habla con Keycloak en cada petición: valida los tokens con las claves públicas que ya tiene en caché.| Pieza nueva | Qué es |
|---|---|
cmd/api | api-pedidos, en el puerto 8081. |
internal/apiauth | El middleware que valida access tokens. |
internal/api, internal/pedidos | Handlers y pedidos en memoria, ahora asociados al sub del usuario. |
cmd/token | Consigue un access token desde la terminal (flujo de dispositivo). |
internal/pedidosclient | Lo 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
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 (
…0a1y…0c1), para que los pedidos de ejemplo puedan asociarse a susub. - 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.
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"
}
}
]
}
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:
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"
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ón | Quién la hace | Qué evita |
|---|---|---|
Firma con la clave del kid | go-oidc (JWKS) | Tokens fabricados o modificados. |
alg permitido | go-oidc (los del descubrimiento) | alg: none y confusión de algoritmos. |
iss exacto | go-oidc | Tokens de otro realm u otro Keycloak. |
aud contiene api-pedidos | go-oidc (ClientID) | Tokens emitidos para otra API. |
exp | go-oidc | Tokens caducados (o robados hace tiempo). |
typ == "Bearer" | Nosotros | Usar 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(elsub). 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:
// 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.
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ón | Una verificación de firma, en memoria | Una llamada HTTP a Keycloak |
| Si Keycloak cae | La API sigue funcionando | La API deja de funcionar |
| Revocación | Se nota cuando el token caduca (≤ 5 min) | Inmediata |
| Tokens opacos (no JWT) | No sirve | Sí |
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.
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.