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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/admin/main.go | modificado | +2 −1 |
cmd/facturacion/main.go | modificado | +2 −1 |
cmd/provision/main.go | modificado | +2 −1 |
cmd/web/main.go | modificado | +2 −1 |
infra/produccion/Caddyfile | nuevo | +8 −0 |
infra/produccion/Dockerfile | nuevo | +12 −0 |
infra/produccion/docker-compose.yml | nuevo | +105 −0 |
infra/produccion/realm/corporativo-realm.json | nuevo | +91 −0 |
infra/produccion/realm/tienda-realm.json | nuevo | +1498 −0 |
infra/produccion/secretos/produccion.env | nuevo | +7 −0 |
internal/apiauth/apiauth.go | modificado | +3 −0 |
internal/auth/auth.go | modificado | +22 −7 |
internal/config/config.go | nuevo | +31 −0 |
cmd/admin/main.go
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, ¤t).Token()
+ tok, err := a.oauth.TokenSource(a.withClient(ctx), ¤t).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
@@ -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
+}