Qué cambió — paso 05
Qué cambió · paso-04 → paso-05

Cambios de la lección 05

Todo lo que cambia en tienda/pasos/paso-05 respecto al paso anterior. Vuelve a la lección: 05. Middleware JWT con JWKS.

11 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.

ArchivoEstadoLíneas
cmd/api/main.gonuevo+81 −0
cmd/token/main.gonuevo+82 −0
cmd/web/main.gomodificado+3 −1
infra/realm/tienda-realm.jsonmodificado+800 −10
internal/api/api.gonuevo+45 −0
internal/apiauth/apiauth.gonuevo+131 −0
internal/jsonhttp/jsonhttp.gonuevo+20 −0
internal/pedidos/pedidos.gonuevo+68 −0
internal/pedidosclient/client.gonuevo+77 −0
internal/web/templates/pedidos.htmlmodificado+4 −2
internal/web/web.gomodificado+21 −27
cmd/api/main.go nuevo · +81 −0
@@ -0,0 +1,81 @@
+// 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
+}
cmd/token/main.go nuevo · +82 −0
@@ -0,0 +1,82 @@
+// 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)
+	}
+}
cmd/web/main.go modificado · +3 −1
@@ -15,6 +15,7 @@
 	"time"
 
 	"tienda/internal/auth"
+	"tienda/internal/pedidosclient"
 	"tienda/internal/session"
 	"tienda/internal/web"
 )
@@ -29,6 +30,7 @@
 		PostLogoutRedirectURL: env("OIDC_POST_LOGOUT_URL", "http://localhost:3000/"),
 	}
 	addr := env("ADDR", ":3000")
+	apiURL := env("API_URL", "http://localhost:8081") // api-pedidos
 
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
@@ -41,7 +43,7 @@
 
 	mux := http.NewServeMux()
 	a.Register(mux)
-	web.Register(mux, a)
+	web.Register(mux, a, pedidosclient.New(apiURL))
 
 	srv := &http.Server{
 		Addr: addr,
infra/realm/tienda-realm.json modificado · +800 −10 · generado, plegado
@@ -30,9 +30,15 @@
         "description": "${role_default-roles}",
         "composite": true,
         "composites": {
-          "realm": ["offline_access", "uma_authorization"],
+          "realm": [
+            "offline_access",
+            "uma_authorization"
+          ],
           "client": {
-            "account": ["view-profile", "manage-account"]
+            "account": [
+              "view-profile",
+              "manage-account"
+            ]
           }
         }
       }
@@ -45,6 +51,7 @@
   },
   "users": [
     {
+      "id": "00000000-0000-4000-8000-0000000000a1",
       "username": "ana",
       "enabled": true,
       "email": "ana@tienda.test",
@@ -52,11 +59,19 @@
       "firstName": "Ana",
       "lastName": "Cliente",
       "credentials": [
-        { "type": "password", "value": "ana123", "temporary": false }
+        {
+          "type": "password",
+          "value": "ana123",
+          "temporary": false
+        }
       ],
-      "realmRoles": ["default-roles-tienda", "cliente"]
-    },
-    {
+      "realmRoles": [
+        "default-roles-tienda",
+        "cliente"
+      ]
+    },
+    {
+      "id": "00000000-0000-4000-8000-0000000000c1",
       "username": "carlos",
       "enabled": true,
       "email": "carlos@tienda.test",
@@ -64,9 +79,17 @@
       "firstName": "Carlos",
       "lastName": "Admin",
       "credentials": [
-        { "type": "password", "value": "carlos123", "temporary": false }
+        {
+          "type": "password",
+          "value": "carlos123",
+          "temporary": false
+        }
       ],
-      "realmRoles": ["default-roles-tienda", "cliente", "admin"]
+      "realmRoles": [
+        "default-roles-tienda",
+        "cliente",
+        "admin"
+      ]
     }
   ],
   "clients": [
@@ -85,12 +108,779 @@
       "serviceAccountsEnabled": false,
       "rootUrl": "http://localhost:3000",
       "baseUrl": "/",
-      "redirectUris": ["http://localhost:3000/callback"],
-      "webOrigins": ["http://localhost:3000"],
+      "redirectUris": [
+        "http://localhost:3000/callback"
+      ],
+      "webOrigins": [
+        "http://localhost:3000"
+      ],
       "attributes": {
         "pkce.code.challenge.method": "S256",
         "post.logout.redirect.uris": "http://localhost:3000/"
+      },
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "profile",
+        "roles",
+        "basic",
+        "email",
+        "api-pedidos"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
+        "organization",
+        "offline_access",
+        "microprofile-jwt"
+      ]
+    },
+    {
+      "clientId": "tienda-cli",
+      "name": "Tienda CLI",
+      "description": "Herramienta de línea de comandos (Device Authorization Grant)",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": true,
+      "standardFlowEnabled": false,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": false,
+      "attributes": {
+        "oauth2.device.authorization.grant.enabled": "true"
+      },
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "profile",
+        "roles",
+        "basic",
+        "email",
+        "api-pedidos"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
+        "organization",
+        "offline_access",
+        "microprofile-jwt"
+      ]
+    },
+    {
+      "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"
+      ]
+    }
+  ],
+  "clientScopes": [
+    {
+      "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"
+          }
+        }
+      ]
+    },
+    {
+      "name": "email",
+      "description": "OpenID Connect built-in scope: email",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "consent.screen.text": "${emailScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "email",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "email",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "email",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "email verified",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-property-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "emailVerified",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "email_verified",
+            "jsonType.label": "boolean"
+          }
+        }
+      ]
+    },
+    {
+      "name": "offline_access",
+      "description": "OpenID Connect built-in scope: offline_access",
+      "protocol": "openid-connect",
+      "attributes": {
+        "consent.screen.text": "${offlineAccessScopeConsentText}",
+        "display.on.consent.screen": "true"
       }
+    },
+    {
+      "name": "web-origins",
+      "description": "OpenID Connect scope for add allowed web origins to the access token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "allowed web origins",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-allowed-origins-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        }
+      ]
+    },
+    {
+      "name": "AuthnContextClassRef",
+      "description": "AuthnContextClassRef Level of Authentiation",
+      "protocol": "saml",
+      "attributes": {},
+      "protocolMappers": [
+        {
+          "name": "AuthnContextClassRef",
+          "protocol": "saml",
+          "protocolMapper": "saml-authn-context-class-ref-mapper",
+          "consentRequired": false,
+          "config": {}
+        }
+      ]
+    },
+    {
+      "name": "service_account",
+      "description": "Specific scope for a client enabled for service accounts",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "Client Host",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientHost",
+            "id.token.claim": "true",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientHost",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "Client ID",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "client_id",
+            "id.token.claim": "true",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "client_id",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "Client IP Address",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientAddress",
+            "id.token.claim": "true",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientAddress",
+            "jsonType.label": "String"
+          }
+        }
+      ]
+    },
+    {
+      "name": "address",
+      "description": "OpenID Connect built-in scope: address",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "consent.screen.text": "${addressScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "address",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-address-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute.formatted": "formatted",
+            "user.attribute.country": "country",
+            "introspection.token.claim": "true",
+            "user.attribute.postal_code": "postal_code",
+            "userinfo.token.claim": "true",
+            "user.attribute.street": "street",
+            "id.token.claim": "true",
+            "user.attribute.region": "region",
+            "access.token.claim": "true",
+            "user.attribute.locality": "locality"
+          }
+        }
+      ]
+    },
+    {
+      "name": "phone",
+      "description": "OpenID Connect built-in scope: phone",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "consent.screen.text": "${phoneScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "phone number verified",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "phoneNumberVerified",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "phone_number_verified",
+            "jsonType.label": "boolean"
+          }
+        },
+        {
+          "name": "phone number",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "phoneNumber",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "phone_number",
+            "jsonType.label": "String"
+          }
+        }
+      ]
+    },
+    {
+      "name": "basic",
+      "description": "OpenID Connect scope for add all basic claims to the token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "sub",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-sub-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        },
+        {
+          "name": "auth_time",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "AUTH_TIME",
+            "id.token.claim": "true",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "auth_time",
+            "jsonType.label": "long"
+          }
+        }
+      ]
+    },
+    {
+      "name": "organization",
+      "description": "Additional claims about the organization a subject belongs to",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "consent.screen.text": "${organizationScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "organization",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-organization-membership-mapper",
+          "consentRequired": false,
+          "config": {
+            "id.token.claim": "true",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "organization",
+            "jsonType.label": "String",
+            "multivalued": "true"
+          }
+        }
+      ]
+    },
+    {
+      "name": "role_list",
+      "description": "SAML role list",
+      "protocol": "saml",
+      "attributes": {
+        "consent.screen.text": "${samlRoleListScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "role list",
+          "protocol": "saml",
+          "protocolMapper": "saml-role-list-mapper",
+          "consentRequired": false,
+          "config": {
+            "single": "false",
+            "attribute.nameformat": "Basic",
+            "attribute.name": "Role"
+          }
+        }
+      ]
+    },
+    {
+      "name": "acr",
+      "description": "OpenID Connect scope for add acr (authentication context class reference) to the token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "acr loa level",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-acr-mapper",
+          "consentRequired": false,
+          "config": {
+            "id.token.claim": "true",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        }
+      ]
+    },
+    {
+      "name": "profile",
+      "description": "OpenID Connect built-in scope: profile",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "consent.screen.text": "${profileScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "birthdate",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "birthdate",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "birthdate",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "updated at",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "updatedAt",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "updated_at",
+            "jsonType.label": "long"
+          }
+        },
+        {
+          "name": "full name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-full-name-mapper",
+          "consentRequired": false,
+          "config": {
+            "id.token.claim": "true",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "picture",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "picture",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "picture",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "middle name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "middleName",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "middle_name",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "zoneinfo",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "zoneinfo",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "zoneinfo",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "profile",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "profile",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "profile",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "family name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "lastName",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "family_name",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "given name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "firstName",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "given_name",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "username",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "username",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "preferred_username",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "locale",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "locale",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "locale",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "nickname",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "nickname",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "nickname",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "website",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "website",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "website",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "gender",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "gender",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "gender",
+            "jsonType.label": "String"
+          }
+        }
+      ]
+    },
+    {
+      "name": "microprofile-jwt",
+      "description": "Microprofile - JWT built-in scope",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "upn",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "user.attribute": "username",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "upn",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "groups",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-realm-role-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "multivalued": "true",
+            "user.attribute": "foo",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "groups",
+            "jsonType.label": "String"
+          }
+        }
+      ]
+    },
+    {
+      "name": "saml_organization",
+      "description": "Organization Membership",
+      "protocol": "saml",
+      "attributes": {
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "organization",
+          "protocol": "saml",
+          "protocolMapper": "saml-organization-membership-mapper",
+          "consentRequired": false,
+          "config": {}
+        }
+      ]
+    },
+    {
+      "name": "roles",
+      "description": "OpenID Connect scope for add user roles to the access token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "consent.screen.text": "${rolesScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "client roles",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-client-role-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "foo",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "resource_access.${client_id}.roles",
+            "jsonType.label": "String",
+            "multivalued": "true"
+          }
+        },
+        {
+          "name": "audience resolve",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-audience-resolve-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        },
+        {
+          "name": "realm roles",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-realm-role-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "foo",
+            "introspection.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "realm_access.roles",
+            "jsonType.label": "String",
+            "multivalued": "true"
+          }
+        }
+      ]
     }
+  ],
+  "defaultDefaultClientScopes": [
+    "role_list",
+    "saml_organization",
+    "AuthnContextClassRef",
+    "profile",
+    "email",
+    "roles",
+    "web-origins",
+    "acr",
+    "basic"
+  ],
+  "defaultOptionalClientScopes": [
+    "offline_access",
+    "address",
+    "phone",
+    "microprofile-jwt",
+    "organization"
   ]
 }
internal/api/api.go nuevo · +45 −0
@@ -0,0 +1,45 @@
+// 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)
+}
internal/apiauth/apiauth.go nuevo · +131 −0
@@ -0,0 +1,131 @@
+// 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)
+}
internal/jsonhttp/jsonhttp.go nuevo · +20 −0
@@ -0,0 +1,20 @@
+// Package jsonhttp tiene dos ayudas para responder JSON desde una API.
+package jsonhttp
+
+import (
+	"encoding/json"
+	"net/http"
+)
+
+// Write responde con v codificado en JSON.
+func Write(w http.ResponseWriter, status int, v any) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(v)
+}
+
+// Error responde con un error en el formato de OAuth 2.0:
+// {"error": "...", "error_description": "..."}.
+func Error(w http.ResponseWriter, status int, code, description string) {
+	Write(w, status, map[string]string{"error": code, "error_description": description})
+}
internal/pedidos/pedidos.go nuevo · +68 −0
@@ -0,0 +1,68 @@
+// 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
+}
internal/pedidosclient/client.go nuevo · +77 −0
@@ -0,0 +1,77 @@
+// 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)
+}
internal/web/templates/pedidos.html modificado · +4 −2
@@ -1,7 +1,9 @@
 {{define "content"}}
 <h1>Mis pedidos</h1>
-<p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}})</p>
-{{if .Orders}}
+<p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}}), servidos por <code>api-pedidos</code></p>
+{{if .Error}}
+<div class="note">⚠ {{.Error}}</div>
+{{else if .Orders}}
 <table>
   <thead><tr><th>Nº</th><th>Artículos</th><th>Total</th><th>Estado</th></tr></thead>
   <tbody>
internal/web/web.go modificado · +21 −27
@@ -12,6 +12,7 @@
 	"time"
 
 	"tienda/internal/auth"
+	"tienda/internal/pedidosclient"
 	"tienda/internal/session"
 )
 
@@ -25,30 +26,11 @@
 	Price float64
 }
 
-// Order es un pedido de ejemplo. En el módulo 3 vendrán de api-pedidos.
-type Order struct {
-	ID     int
-	Items  string
-	Total  float64
-	Status string
-}
-
 var catalog = []Product{
 	{"Gopher de peluche", "El compañero ideal para depurar.", 19.90},
 	{"Taza «go fmt»", "Formatea tu café automáticamente.", 9.50},
 	{"Camiseta Keycloak", "Algodón 100 %, talla única de realm.", 15.00},
 	{"Pegatinas OIDC", "Pack de 10: iss, sub, aud, exp…", 4.99},
-}
-
-// ordersByUser simula una base de datos de pedidos, indexada por username.
-var ordersByUser = map[string][]Order{
-	"ana": {
-		{1001, "Gopher de peluche ×1, Pegatinas OIDC ×2", 29.88, "Enviado"},
-		{1002, "Taza «go fmt» ×1", 9.50, "Pendiente"},
-	},
-	"carlos": {
-		{1003, "Camiseta Keycloak ×2", 30.00, "Entregado"},
-	},
 }
 
 // claim es una fila de la tabla de /perfil.
@@ -69,7 +51,7 @@
 	Active   string // pestaña activa del menú
 	Session  *session.Session
 	Products []Product
-	Orders   []Order
+	Orders   []pedidosclient.Order
 	Claims   []claim
 	UserInfo []claim
 	Token    tokenInfo
@@ -78,12 +60,13 @@
 
 type handlers struct {
 	auth  *auth.Auth
+	api   *pedidosclient.Client
 	pages map[string]*template.Template
 }
 
 // Register añade las páginas al mux. /pedidos y /perfil exigen sesión.
-func Register(mux *http.ServeMux, a *auth.Auth) {
-	h := &handlers{auth: a, pages: make(map[string]*template.Template)}
+func Register(mux *http.ServeMux, a *auth.Auth, api *pedidosclient.Client) {
+	h := &handlers{auth: a, api: api, pages: make(map[string]*template.Template)}
 	for _, name := range []string{"home.html", "pedidos.html", "perfil.html"} {
 		h.pages[name] = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/"+name))
 	}
@@ -99,13 +82,24 @@
 	h.render(w, "home.html", pageData{Active: "catalogo", Session: sess, Products: catalog})
 }
 
+// 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)
-	h.render(w, "pedidos.html", pageData{
-		Active:  "pedidos",
-		Session: sess,
-		Orders:  ordersByUser[sess.User.Username],
-	})
+	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)
 }
 
 // perfil muestra los claims del ID token, el estado de los tokens y los datos

← Volver a la lección 05