Qué cambió — paso 16
Qué cambió · paso-15 → paso-16

Cambios de la lección 16

Todo lo que cambia en tienda/pasos/paso-16 respecto al paso anterior. Vuelve a la lección: 16. Keycloak en producción.

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

cmd/admin/main.go modificado · +2 −1
@@ -25,6 +25,7 @@
 	"time"
 
 	"tienda/internal/admin"
+	"tienda/internal/config"
 )
 
 func main() {
@@ -41,7 +42,7 @@
 		URL:          env("KEYCLOAK_URL", "http://localhost:8080"),
 		Realm:        env("KEYCLOAK_REALM", "tienda"),
 		ClientID:     env("ADMIN_CLIENT_ID", "admin-tool"),
-		ClientSecret: env("ADMIN_CLIENT_SECRET", "admin-tool-secret"), // solo para desarrollo
+		ClientSecret: config.Secret("ADMIN_CLIENT_SECRET", "admin-tool-secret"), // el valor por defecto, solo en desarrollo
 	})
 	if err != nil {
 		log.Fatal(err)
cmd/facturacion/main.go modificado · +2 −1
@@ -26,6 +26,7 @@
 	"golang.org/x/oauth2/clientcredentials"
 
 	"tienda/internal/apiauth"
+	"tienda/internal/config"
 	"tienda/internal/facturacion"
 	"tienda/internal/tokenexchange"
 )
@@ -33,7 +34,7 @@
 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
+	clientSecret := config.Secret("OIDC_CLIENT_SECRET", "facturacion-secret") // el valor por defecto, solo en desarrollo
 	apiURL := env("API_URL", "http://localhost:8081")
 	addr := env("ADDR", ":8082")
 	interval, err := time.ParseDuration(env("INTERVALO", "30s"))
cmd/provision/main.go modificado · +2 −1
@@ -18,6 +18,7 @@
 
 	"github.com/Nerzal/gocloak/v14"
 
+	"tienda/internal/config"
 	"tienda/internal/provision"
 )
 
@@ -41,7 +42,7 @@
 
 	realm := env("KEYCLOAK_REALM", "tienda")
 	kc := gocloak.NewClient(env("KEYCLOAK_URL", "http://localhost:8080"))
-	jwt, err := kc.LoginClient(ctx, env("ADMIN_CLIENT_ID", "admin-tool"), env("ADMIN_CLIENT_SECRET", "admin-tool-secret"), realm)
+	jwt, err := kc.LoginClient(ctx, env("ADMIN_CLIENT_ID", "admin-tool"), config.Secret("ADMIN_CLIENT_SECRET", "admin-tool-secret"), realm)
 	if err != nil {
 		log.Fatalf("login de admin-tool: %v", err)
 	}
cmd/web/main.go modificado · +2 −1
@@ -15,6 +15,7 @@
 	"time"
 
 	"tienda/internal/auth"
+	"tienda/internal/config"
 	"tienda/internal/facturasclient"
 	"tienda/internal/pedidosclient"
 	"tienda/internal/session"
@@ -25,7 +26,7 @@
 	cfg := auth.Config{
 		Issuer:       env("OIDC_ISSUER", "http://localhost:8080/realms/tienda"),
 		ClientID:     env("OIDC_CLIENT_ID", "tienda-web"),
-		ClientSecret: env("OIDC_CLIENT_SECRET", "tienda-web-secret"), // solo para desarrollo
+		ClientSecret: config.Secret("OIDC_CLIENT_SECRET", "tienda-web-secret"), // el valor por defecto, solo en desarrollo
 		RedirectURL:  env("OIDC_REDIRECT_URL", "http://localhost:3000/callback"),
 
 		PostLogoutRedirectURL: env("OIDC_POST_LOGOUT_URL", "http://localhost:3000/"),
infra/produccion/Caddyfile nuevo · +8 −0
@@ -0,0 +1,8 @@
+# Caddy termina TLS y pasa las peticiones a Keycloak por la red interna.
+# «tls internal»: Caddy crea su propia CA y un certificado para localhost.
+# En un servidor real pondrías tu dominio y Caddy pediría el certificado a
+# Let's Encrypt (o usarías el de tu empresa).
+https://localhost:8443 {
+	tls internal
+	reverse_proxy keycloak:8080
+}
infra/produccion/Dockerfile nuevo · +12 −0
@@ -0,0 +1,12 @@
+# Keycloak «optimizado» para producción (lección 16): las opciones de build
+# (base de datos, health, métricas) se fijan al construir la imagen, y el
+# servidor arranca con «start --optimized», sin recompilarse en cada inicio.
+FROM quay.io/keycloak/keycloak:26.8.0 AS builder
+ENV KC_DB=postgres
+ENV KC_HEALTH_ENABLED=true
+ENV KC_METRICS_ENABLED=true
+RUN /opt/keycloak/bin/kc.sh build
+
+FROM quay.io/keycloak/keycloak:26.8.0
+COPY --from=builder /opt/keycloak/ /opt/keycloak/
+ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
infra/produccion/docker-compose.yml nuevo · +105 −0
@@ -0,0 +1,105 @@
+# La tienda con un Keycloak «como en producción» (lección 16). Solo cambia la
+# infraestructura: el realm, los usuarios y el AD de prueba son los del paso.
+#
+#   cd infra/produccion
+#   docker compose up -d --build
+#   docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
+#
+# Keycloak queda en https://localhost:8443 (a través de Caddy). Su puerto
+# 8080 y el de gestión (9000) no se publican: solo los ve la red interna.
+services:
+  ad:
+    image: instantlinux/samba-dc:4.23.10-r0
+    hostname: dc1
+    cap_add: [SYS_ADMIN]
+    environment:
+      REALM: tienda.local
+      WORKGROUP: TIENDA
+      NETBIOS_NAME: DC1
+      DOMAIN_ACTION: provision
+      BIND_INTERFACES_ONLY: "no"
+    secrets: [samba-admin-password]
+    volumes:
+      - ad-etc:/etc/samba
+      - ad-lib:/var/lib/samba
+      - ../ad/0globals.conf:/etc/samba/conf.d/0globals.conf:ro
+
+  ad-seed:
+    image: instantlinux/samba-dc:4.23.10-r0
+    entrypoint: ["sh", "/seed.sh"]
+    environment:
+      AD_ADMIN_PASSWORD_FILE: /run/secrets/samba-admin-password
+    secrets: [samba-admin-password]
+    volumes:
+      - ../ad/seed.sh:/seed.sh:ro
+    depends_on: [ad]
+
+  postgres:
+    image: postgres:17
+    environment:
+      POSTGRES_DB: keycloak
+      POSTGRES_USER: keycloak
+    env_file: secretos/produccion.env   # POSTGRES_PASSWORD
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
+      interval: 5s
+      timeout: 3s
+      retries: 10
+
+  keycloak:
+    build: .
+    command: start --optimized --import-realm
+    environment:
+      # URL pública: la que ven navegadores y programas, y la que va en «iss».
+      KC_HOSTNAME: https://localhost:8443
+      # TLS lo termina Caddy: Keycloak escucha HTTP solo en la red interna y
+      # se fía de las cabeceras X-Forwarded-* que pone el proxy.
+      KC_HTTP_ENABLED: "true"
+      KC_PROXY_HEADERS: xforwarded
+      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
+      KC_DB_USERNAME: keycloak
+      # Admin temporal para el primer arranque: crea uno permanente y bórralo.
+      KC_BOOTSTRAP_ADMIN_USERNAME: admin
+    # KC_DB_PASSWORD y KC_BOOTSTRAP_ADMIN_PASSWORD: fuera de este archivo.
+    env_file: secretos/produccion.env
+    volumes:
+      - ./realm:/opt/keycloak/data/import:ro
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    healthcheck:
+      # La imagen no trae curl: preguntamos a /health/ready (puerto de gestión)
+      # con bash y miramos el código HTTP: 200 = listo, 503 = aún no. (Buscar
+      # «UP» en el cuerpo no sirve: los checks internos dicen UP antes.)
+      test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q ' 200 '"]
+      interval: 10s
+      timeout: 5s
+      retries: 30
+      start_period: 30s
+    depends_on:
+      postgres:
+        condition: service_healthy
+      ad-seed:
+        condition: service_completed_successfully
+
+  caddy:
+    image: caddy:2.11.7-alpine
+    ports:
+      - "127.0.0.1:8443:8443"
+    volumes:
+      - ./Caddyfile:/etc/caddy/Caddyfile:ro
+      - caddy-data:/data
+    depends_on:
+      keycloak:
+        condition: service_healthy
+
+secrets:
+  samba-admin-password:
+    file: ../ad/admin-password
+
+volumes:
+  pgdata:
+  ad-etc:
+  ad-lib:
+  caddy-data:
infra/produccion/realm/corporativo-realm.json nuevo · +91 −0
@@ -0,0 +1,91 @@
+{
+  "realm": "corporativo",
+  "displayName": "Empresa S.A. (simula Entra ID)",
+  "enabled": true,
+  "sslRequired": "external",
+  "registrationAllowed": false,
+  "loginWithEmailAllowed": true,
+  "groups": [
+    {
+      "name": "tienda-compradores"
+    },
+    {
+      "name": "tienda-admins"
+    }
+  ],
+  "users": [
+    {
+      "username": "lucia",
+      "firstName": "Lucía",
+      "lastName": "Marín",
+      "email": "lucia@empresa.test",
+      "emailVerified": true,
+      "enabled": true,
+      "credentials": [
+        {
+          "type": "password",
+          "value": "Lucia-Empresa-2026!",
+          "temporary": false
+        }
+      ],
+      "groups": [
+        "/tienda-compradores"
+      ]
+    },
+    {
+      "username": "jorge",
+      "firstName": "Jorge",
+      "lastName": "Paz",
+      "email": "jorge@empresa.test",
+      "emailVerified": true,
+      "enabled": true,
+      "credentials": [
+        {
+          "type": "password",
+          "value": "Jorge-Empresa-2026!",
+          "temporary": false
+        }
+      ],
+      "groups": [
+        "/tienda-compradores",
+        "/tienda-admins"
+      ]
+    }
+  ],
+  "clients": [
+    {
+      "clientId": "tienda-broker",
+      "name": "Tienda Go (vía Keycloak tienda)",
+      "description": "El realm tienda entra aquí como una aplicación más, igual que haría con Entra ID",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": false,
+      "secret": "tienda-broker-secret",
+      "standardFlowEnabled": true,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": false,
+      "redirectUris": [
+        "https://localhost:8443/realms/tienda/broker/corporativo/endpoint"
+      ],
+      "attributes": {
+        "pkce.code.challenge.method": "S256",
+        "post.logout.redirect.uris": "https://localhost:8443/realms/tienda/broker/corporativo/endpoint/logout_response",
+        "backchannel.logout.session.required": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "groups",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-group-membership-mapper",
+          "config": {
+            "claim.name": "groups",
+            "full.path": "false",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "userinfo.token.claim": "true"
+          }
+        }
+      ]
+    }
+  ]
+}
infra/produccion/realm/tienda-realm.json nuevo · +1498 −0
@@ -0,0 +1,1498 @@
+{
+  "realm": "tienda",
+  "displayName": "Tienda Go",
+  "enabled": true,
+  "sslRequired": "external",
+  "registrationAllowed": false,
+  "loginWithEmailAllowed": true,
+  "accessTokenLifespan": 300,
+  "ssoSessionIdleTimeout": 1800,
+  "roles": {
+    "realm": [
+      {
+        "name": "cliente",
+        "description": "Puede ver el catálogo y gestionar sus propios pedidos"
+      },
+      {
+        "name": "admin",
+        "description": "Puede ver y gestionar todos los pedidos"
+      },
+      {
+        "name": "offline_access",
+        "description": "${role_offline-access}"
+      },
+      {
+        "name": "uma_authorization",
+        "description": "${role_uma_authorization}"
+      },
+      {
+        "name": "default-roles-tienda",
+        "description": "${role_default-roles}",
+        "composite": true,
+        "composites": {
+          "realm": [
+            "offline_access",
+            "uma_authorization"
+          ],
+          "client": {
+            "account": [
+              "view-profile",
+              "manage-account"
+            ]
+          }
+        }
+      }
+    ],
+    "client": {
+      "api-pedidos": [
+        {
+          "name": "facturar",
+          "description": "Leer los pedidos de todos los clientes para facturarlos (para servicios)"
+        }
+      ]
+    }
+  },
+  "defaultRole": {
+    "name": "default-roles-tienda",
+    "description": "${role_default-roles}",
+    "composite": true
+  },
+  "users": [
+    {
+      "id": "00000000-0000-4000-8000-0000000000a1",
+      "username": "ana",
+      "enabled": true,
+      "email": "ana@tienda.test",
+      "emailVerified": true,
+      "firstName": "Ana",
+      "lastName": "Cliente",
+      "credentials": [
+        {
+          "type": "password",
+          "value": "ana123",
+          "temporary": false
+        }
+      ],
+      "realmRoles": [
+        "default-roles-tienda",
+        "cliente"
+      ]
+    },
+    {
+      "id": "00000000-0000-4000-8000-0000000000c1",
+      "username": "carlos",
+      "enabled": true,
+      "email": "carlos@tienda.test",
+      "emailVerified": true,
+      "firstName": "Carlos",
+      "lastName": "Admin",
+      "credentials": [
+        {
+          "type": "password",
+          "value": "carlos123",
+          "temporary": false
+        },
+        {
+          "type": "otp",
+          "userLabel": "móvil de carlos",
+          "secretData": "{\"value\": \"carlos-otp-curso-2026\"}",
+          "credentialData": "{\"subType\": \"totp\", \"digits\": 6, \"counter\": 0, \"period\": 30, \"algorithm\": \"HmacSHA1\"}"
+        }
+      ],
+      "realmRoles": [
+        "default-roles-tienda",
+        "cliente",
+        "admin"
+      ]
+    },
+    {
+      "username": "service-account-facturacion",
+      "enabled": true,
+      "serviceAccountClientId": "facturacion",
+      "realmRoles": [
+        "default-roles-tienda"
+      ],
+      "clientRoles": {
+        "api-pedidos": [
+          "facturar"
+        ]
+      }
+    },
+    {
+      "username": "service-account-admin-tool",
+      "enabled": true,
+      "serviceAccountClientId": "admin-tool",
+      "realmRoles": [
+        "default-roles-tienda"
+      ],
+      "clientRoles": {
+        "realm-management": [
+          "manage-users",
+          "view-realm",
+          "view-clients",
+          "manage-clients"
+        ]
+      }
+    }
+  ],
+  "clients": [
+    {
+      "clientId": "tienda-web",
+      "name": "Tienda Web",
+      "description": "Aplicación web en Go (login con Authorization Code + PKCE)",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": false,
+      "clientAuthenticatorType": "client-secret",
+      "secret": "tienda-web-secret",
+      "standardFlowEnabled": true,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": false,
+      "rootUrl": "http://localhost:3000",
+      "baseUrl": "/",
+      "redirectUris": [
+        "http://localhost:3000/callback"
+      ],
+      "webOrigins": [
+        "http://localhost:3000"
+      ],
+      "attributes": {
+        "pkce.code.challenge.method": "S256",
+        "post.logout.redirect.uris": "http://localhost:3000/"
+      },
+      "protocolMappers": [
+        {
+          "name": "roles de realm en el ID token",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-realm-role-mapper",
+          "consentRequired": false,
+          "config": {
+            "claim.name": "roles",
+            "jsonType.label": "String",
+            "multivalued": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "false",
+            "userinfo.token.claim": "false",
+            "introspection.token.claim": "false"
+          }
+        }
+      ],
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "profile",
+        "roles",
+        "basic",
+        "email",
+        "api-pedidos",
+        "facturacion"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
+        "organization",
+        "offline_access",
+        "microprofile-jwt",
+        "pedidos:escribir"
+      ]
+    },
+    {
+      "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",
+        "pedidos:escribir",
+        "facturacion"
+      ]
+    },
+    {
+      "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"
+      ]
+    },
+    {
+      "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"
+      ]
+    },
+    {
+      "clientId": "admin-tool",
+      "name": "Herramienta de administración",
+      "description": "CLI de administración con gocloak (módulo 5)",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": false,
+      "clientAuthenticatorType": "client-secret",
+      "secret": "admin-tool-secret",
+      "standardFlowEnabled": false,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": true,
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "profile",
+        "roles",
+        "basic",
+        "email"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
+        "organization",
+        "offline_access",
+        "microprofile-jwt"
+      ]
+    }
+  ],
+  "components": {
+    "org.keycloak.storage.UserStorageProvider": [
+      {
+        "name": "active-directory",
+        "providerId": "ldap",
+        "subComponents": {
+          "org.keycloak.storage.ldap.mappers.LDAPStorageMapper": [
+            {
+              "name": "username",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "sAMAccountName"
+                ],
+                "is.mandatory.in.ldap": [
+                  "true"
+                ],
+                "always.read.value.from.ldap": [
+                  "false"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "username"
+                ]
+              }
+            },
+            {
+              "name": "first name",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "givenName"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "firstName"
+                ]
+              }
+            },
+            {
+              "name": "last name",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "sn"
+                ],
+                "is.mandatory.in.ldap": [
+                  "true"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "lastName"
+                ]
+              }
+            },
+            {
+              "name": "email",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "mail"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "always.read.value.from.ldap": [
+                  "false"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "email"
+                ]
+              }
+            },
+            {
+              "name": "MSAD account controls",
+              "providerId": "msad-user-account-control-mapper",
+              "subComponents": {},
+              "config": {
+                "always.read.enabled.value.from.ldap": [
+                  "true"
+                ]
+              }
+            },
+            {
+              "name": "roles desde grupos de AD",
+              "providerId": "role-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "mode": [
+                  "LDAP_ONLY"
+                ],
+                "membership.attribute.type": [
+                  "DN"
+                ],
+                "roles.dn": [
+                  "OU=Tienda,DC=tienda,DC=local"
+                ],
+                "user.roles.retrieve.strategy": [
+                  "GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE"
+                ],
+                "membership.ldap.attribute": [
+                  "member"
+                ],
+                "membership.user.ldap.attribute": [
+                  "sAMAccountName"
+                ],
+                "role.name.ldap.attribute": [
+                  "cn"
+                ],
+                "memberof.ldap.attribute": [
+                  "memberOf"
+                ],
+                "use.realm.roles.mapping": [
+                  "true"
+                ],
+                "role.object.classes": [
+                  "group"
+                ],
+                "roles.ldap.filter": [
+                  "(|(cn=cliente)(cn=admin))"
+                ]
+              }
+            },
+            {
+              "name": "creation date",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "whenCreated"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "createTimestamp"
+                ]
+              }
+            },
+            {
+              "name": "modify date",
+              "providerId": "user-attribute-ldap-mapper",
+              "subComponents": {},
+              "config": {
+                "ldap.attribute": [
+                  "whenChanged"
+                ],
+                "is.mandatory.in.ldap": [
+                  "false"
+                ],
+                "read.only": [
+                  "true"
+                ],
+                "always.read.value.from.ldap": [
+                  "true"
+                ],
+                "user.model.attribute": [
+                  "modifyTimestamp"
+                ]
+              }
+            },
+            {
+              "name": "Kerberos principal attribute mapper",
+              "providerId": "kerberos-principal-attribute-mapper",
+              "subComponents": {},
+              "config": {}
+            }
+          ]
+        },
+        "config": {
+          "authType": [
+            "simple"
+          ],
+          "bindCredential": [
+            "Keycloak-Lectura-2026!"
+          ],
+          "bindDn": [
+            "CN=svc-keycloak,CN=Users,DC=tienda,DC=local"
+          ],
+          "cachePolicy": [
+            "NO_CACHE"
+          ],
+          "changedSyncPeriod": [
+            "-1"
+          ],
+          "connectionUrl": [
+            "ldap://ad:389"
+          ],
+          "editMode": [
+            "READ_ONLY"
+          ],
+          "enabled": [
+            "true"
+          ],
+          "fullSyncPeriod": [
+            "-1"
+          ],
+          "importEnabled": [
+            "true"
+          ],
+          "krbPrincipalAttribute": [
+            "userPrincipalName"
+          ],
+          "pagination": [
+            "true"
+          ],
+          "priority": [
+            "0"
+          ],
+          "rdnLDAPAttribute": [
+            "cn"
+          ],
+          "searchScope": [
+            "2"
+          ],
+          "syncRegistrations": [
+            "false"
+          ],
+          "trustEmail": [
+            "true"
+          ],
+          "userObjectClasses": [
+            "person, organizationalPerson, user"
+          ],
+          "usernameLDAPAttribute": [
+            "sAMAccountName"
+          ],
+          "usersDn": [
+            "OU=Tienda,DC=tienda,DC=local"
+          ],
+          "uuidLDAPAttribute": [
+            "objectGUID"
+          ],
+          "vendor": [
+            "ad"
+          ]
+        }
+      }
+    ]
+  },
+  "identityProviders": [
+    {
+      "alias": "corporativo",
+      "displayName": "Cuenta de la empresa",
+      "providerId": "oidc",
+      "enabled": true,
+      "trustEmail": true,
+      "storeToken": false,
+      "firstBrokerLoginFlowAlias": "first broker login",
+      "config": {
+        "issuer": "https://localhost:8443/realms/corporativo",
+        "authorizationUrl": "https://localhost:8443/realms/corporativo/protocol/openid-connect/auth",
+        "tokenUrl": "http://localhost:8080/realms/corporativo/protocol/openid-connect/token",
+        "userInfoUrl": "http://localhost:8080/realms/corporativo/protocol/openid-connect/userinfo",
+        "logoutUrl": "http://localhost:8080/realms/corporativo/protocol/openid-connect/logout",
+        "jwksUrl": "http://localhost:8080/realms/corporativo/protocol/openid-connect/certs",
+        "useJwksUrl": "true",
+        "validateSignature": "true",
+        "clientId": "tienda-broker",
+        "clientSecret": "tienda-broker-secret",
+        "clientAuthMethod": "client_secret_basic",
+        "defaultScope": "openid profile email",
+        "pkceEnabled": "true",
+        "pkceMethod": "S256",
+        "syncMode": "FORCE",
+        "backchannelSupported": "true"
+      }
+    }
+  ],
+  "identityProviderMappers": [
+    {
+      "name": "usuario = email",
+      "identityProviderAlias": "corporativo",
+      "identityProviderMapper": "oidc-username-idp-mapper",
+      "config": {
+        "syncMode": "IMPORT",
+        "template": "${CLAIM.email}"
+      }
+    },
+    {
+      "name": "compradores → cliente",
+      "identityProviderAlias": "corporativo",
+      "identityProviderMapper": "oidc-role-idp-mapper",
+      "config": {
+        "syncMode": "FORCE",
+        "claim": "groups",
+        "claim.value": "tienda-compradores",
+        "role": "cliente"
+      }
+    },
+    {
+      "name": "admins → admin",
+      "identityProviderAlias": "corporativo",
+      "identityProviderMapper": "oidc-role-idp-mapper",
+      "config": {
+        "syncMode": "FORCE",
+        "claim": "groups",
+        "claim.value": "tienda-admins",
+        "role": "admin"
+      }
+    }
+  ],
+  "attributes": {
+    "acr.loa.map": "{\"basico\": 1, \"reforzado\": 2}"
+  },
+  "authenticationFlows": [
+    {
+      "alias": "navegador con niveles",
+      "description": "Login del navegador con dos niveles: contraseña (basico) y contraseña + código (reforzado)",
+      "providerId": "basic-flow",
+      "topLevel": true,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "ALTERNATIVE",
+          "priority": 10,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "auth-cookie"
+        },
+        {
+          "requirement": "ALTERNATIVE",
+          "priority": 20,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "identity-provider-redirector"
+        },
+        {
+          "requirement": "ALTERNATIVE",
+          "priority": 30,
+          "autheticatorFlow": true,
+          "userSetupAllowed": false,
+          "flowAlias": "formularios por nivel"
+        }
+      ]
+    },
+    {
+      "alias": "formularios por nivel",
+      "description": "Un subflujo por nivel, del más bajo al más alto",
+      "providerId": "basic-flow",
+      "topLevel": false,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "CONDITIONAL",
+          "priority": 10,
+          "autheticatorFlow": true,
+          "userSetupAllowed": false,
+          "flowAlias": "nivel 1 - contraseña"
+        },
+        {
+          "requirement": "CONDITIONAL",
+          "priority": 20,
+          "autheticatorFlow": true,
+          "userSetupAllowed": false,
+          "flowAlias": "nivel 2 - código"
+        }
+      ]
+    },
+    {
+      "alias": "nivel 1 - contraseña",
+      "description": "basico (LoA 1): usuario y contraseña",
+      "providerId": "basic-flow",
+      "topLevel": false,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "REQUIRED",
+          "priority": 10,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "conditional-level-of-authentication",
+          "authenticatorConfig": "nivel 1"
+        },
+        {
+          "requirement": "REQUIRED",
+          "priority": 20,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "auth-username-password-form"
+        }
+      ]
+    },
+    {
+      "alias": "nivel 2 - código",
+      "description": "reforzado (LoA 2): además, un código de un solo uso (TOTP)",
+      "providerId": "basic-flow",
+      "topLevel": false,
+      "builtIn": false,
+      "authenticationExecutions": [
+        {
+          "requirement": "REQUIRED",
+          "priority": 10,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "conditional-level-of-authentication",
+          "authenticatorConfig": "nivel 2"
+        },
+        {
+          "requirement": "REQUIRED",
+          "priority": 20,
+          "autheticatorFlow": false,
+          "userSetupAllowed": false,
+          "authenticator": "auth-otp-form"
+        }
+      ]
+    }
+  ],
+  "authenticatorConfig": [
+    {
+      "alias": "nivel 1",
+      "config": {
+        "loa-condition-level": "1",
+        "loa-max-age": "36000"
+      }
+    },
+    {
+      "alias": "nivel 2",
+      "config": {
+        "loa-condition-level": "2",
+        "loa-max-age": "300"
+      }
+    }
+  ],
+  "browserFlow": "navegador con niveles",
+  "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": "pedidos:escribir",
+      "description": "Permite crear pedidos y cambiar su estado",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "display.on.consent.screen": "true",
+        "consent.screen.text": "Crear y modificar pedidos"
+      }
+    },
+    {
+      "name": "facturacion",
+      "description": "Añade facturacion a la audiencia (aud) del access token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "audiencia facturacion",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-audience-mapper",
+          "consentRequired": false,
+          "config": {
+            "included.client.audience": "facturacion",
+            "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"
+  ]
+}
infra/produccion/secretos/produccion.env nuevo · +7 −0
@@ -0,0 +1,7 @@
+# Secretos de la infraestructura de producción (lección 16).
+# VALORES DE EJEMPLO para que el curso funcione: en un despliegue real este
+# archivo no se guarda en Git (usa los secrets de tu orquestador, un gestor
+# como Vault o el «config keystore» de Keycloak).
+POSTGRES_PASSWORD=Postgres-Tienda-2026!
+KC_DB_PASSWORD=Postgres-Tienda-2026!
+KC_BOOTSTRAP_ADMIN_PASSWORD=Admin-Keycloak-2026!
internal/apiauth/apiauth.go modificado · +3 −0
@@ -39,6 +39,9 @@
 // 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) {
+	// El cliente del contexto se usa también, más tarde, para refrescar el
+	// JWKS cuando Keycloak rota las claves: con timeout (lección 16).
+	ctx = oidc.ClientContext(ctx, &http.Client{Timeout: 10 * time.Second})
 	provider, err := oidc.NewProvider(ctx, issuer)
 	if err != nil {
 		return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", issuer, err)
internal/auth/auth.go modificado · +22 −7
@@ -66,7 +66,9 @@
 	provider      *oidc.Provider
 	verifier      *oidc.IDTokenVerifier
 	sessions      *session.Store
-	endSessionURL string // end_session_endpoint del descubrimiento
+	endSessionURL string       // end_session_endpoint del descubrimiento
+	httpClient    *http.Client // para hablar con Keycloak: con timeout (lección 16)
+	secure        bool         // cookies solo por HTTPS: la app se sirve con https://
 	idps          []string
 	acrs          []string
 	postLogoutURL string
@@ -77,6 +79,10 @@
 
 // New lee el documento de descubrimiento del issuer y prepara el cliente OIDC.
 func New(ctx context.Context, cfg Config, sessions *session.Store) (*Auth, error) {
+	// Sin timeout, una petición a un Keycloak que no responde se queda
+	// colgada para siempre. go-oidc y x/oauth2 toman el cliente del contexto.
+	client := &http.Client{Timeout: 10 * time.Second}
+	ctx = oidc.ClientContext(ctx, client)
 	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
 	if err != nil {
 		return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", cfg.Issuer, err)
@@ -106,6 +112,8 @@
 		sessions:      sessions,
 		endSessionURL: meta.EndSessionEndpoint,
 		postLogoutURL: cfg.PostLogoutRedirectURL,
+		httpClient:    client,
+		secure:        strings.HasPrefix(cfg.RedirectURL, "https://"),
 		idps:          cfg.IdentityProviders,
 		acrs:          cfg.ACRValues,
 		pending:       make(map[string]pendingLogin),
@@ -152,6 +160,7 @@
 		MaxAge:   int(pendingTTL.Seconds()),
 		HttpOnly: true,
 		SameSite: http.SameSiteLaxMode,
+		Secure:   a.secure,
 	})
 
 	opts := []oauth2.AuthCodeOption{oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier)}
@@ -185,7 +194,7 @@
 		http.Error(w, "state inválido: vuelve a iniciar sesión", http.StatusBadRequest)
 		return
 	}
-	http.SetCookie(w, &http.Cookie{Name: stateCookie, Path: "/callback", MaxAge: -1})
+	http.SetCookie(w, &http.Cookie{Name: stateCookie, Path: "/callback", MaxAge: -1, Secure: a.secure})
 
 	// 3. Recuperamos nonce y code_verifier (cada state sirve una sola vez).
 	a.mu.Lock()
@@ -199,7 +208,7 @@
 
 	// 4. Canal trasero: canjeamos el código por tokens enviando el code_verifier
 	//    (y el client_secret, que x/oauth2 añade a partir de la Config).
-	tok, err := a.oauth.Exchange(r.Context(), q.Get("code"), oauth2.VerifierOption(p.verifier))
+	tok, err := a.oauth.Exchange(a.withClient(r.Context()), q.Get("code"), oauth2.VerifierOption(p.verifier))
 	if err != nil {
 		log.Printf("canje del código: %v", err)
 		http.Error(w, "no se pudo completar el login", http.StatusBadGateway)
@@ -267,7 +276,7 @@
 		Path:     "/",
 		HttpOnly: true,                 // JavaScript no puede leerla
 		SameSite: http.SameSiteLaxMode, // no viaja en POST de otros sitios
-		// Secure: true,                // obligatorio en producción (HTTPS)
+		Secure:   a.secure,             // solo por HTTPS cuando la app se sirve con https://
 	})
 	http.Redirect(w, r, p.returnTo, http.StatusFound)
 }
@@ -284,7 +293,7 @@
 
 	// TokenSource devuelve el token mientras sea válido y, si no, usa el
 	// refresh token contra el endpoint de token de Keycloak.
-	tok, err := a.oauth.TokenSource(ctx, &current).Token()
+	tok, err := a.oauth.TokenSource(a.withClient(ctx), &current).Token()
 	if err != nil {
 		var re *oauth2.RetrieveError
 		if errors.As(err, &re) && re.ErrorCode == "invalid_grant" {
@@ -304,7 +313,7 @@
 
 // UserInfo llama al endpoint userinfo de Keycloak con el access token.
 func (a *Auth) UserInfo(ctx context.Context, tok *oauth2.Token) (map[string]any, error) {
-	ui, err := a.provider.UserInfo(ctx, oauth2.StaticTokenSource(tok))
+	ui, err := a.provider.UserInfo(a.withClient(ctx), oauth2.StaticTokenSource(tok))
 	if err != nil {
 		return nil, err
 	}
@@ -324,7 +333,7 @@
 		}
 		a.sessions.Delete(c.Value)
 	}
-	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1})
+	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, Secure: a.secure})
 
 	if idToken == "" || a.endSessionURL == "" {
 		http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -418,3 +427,9 @@
 	}
 	return next
 }
+
+// withClient añade al contexto el cliente HTTP con timeout para que x/oauth2 y
+// go-oidc lo usen al hablar con Keycloak.
+func (a *Auth) withClient(ctx context.Context) context.Context {
+	return oidc.ClientContext(ctx, a.httpClient)
+}
internal/config/config.go nuevo · +31 −0
@@ -0,0 +1,31 @@
+// Package config lee la configuración de los programas de la tienda desde
+// variables de entorno (lección 16).
+package config
+
+import (
+	"log"
+	"os"
+)
+
+// Env devuelve la variable de entorno key o, si no está, def.
+func Env(key, def string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return def
+}
+
+// Production indica si el programa corre en producción (TIENDA_ENTORNO=produccion).
+func Production() bool { return os.Getenv("TIENDA_ENTORNO") == "produccion" }
+
+// Secret lee un secreto. En desarrollo usa devDefault si falta; en producción
+// se niega a arrancar: un secreto «de ejemplo» en producción es peor que un error.
+func Secret(key, devDefault string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	if Production() {
+		log.Fatalf("falta %s: en producción los secretos no tienen valor por defecto", key)
+	}
+	return devDefault
+}

← Volver a la lección 16