Entrar con la cuenta de la empresa
Una empresa cliente quiere que sus empleados compren en la tienda con la cuenta corporativa, la de Microsoft 365 (Entra ID). Keycloak hace de intermediario (identity broker): la contraseña nunca pasa por la tienda. Aquí un segundo realm hace de Entra ID, para que puedas probarlo todo en tu máquina.
En este capítulo
- Trabajas en
- Keycloak (un proveedor de identidad externo en el realm
tienda) y un cambio pequeño en tienda-web: el botón «Cuenta de la empresa». - Carpeta
tienda/pasos/paso-12· realm nuevo:docker compose down -ven el paso anterior- Archivos
- Nuevo:
infra/realm/corporativo-realm.json(la «empresa»); cambian el realmtienda,internal/auth/auth.go,cmd/web/main.go, la plantillalayout.htmleinternal/admin/admin.go· ver todos los cambios del paso. - En marcha
- Keycloak y el AD del paso 12, api, web y facturacion.
- Comprueba
go -C tools/comprobar run . -paso 12 -servicios(desdecourse/)
Al terminar sabrás
- Qué es el identity brokering y en qué se diferencia de la federación con AD de la lección 11.
- Configurar un proveedor OpenID Connect externo y sus mappers (usuario, grupos → roles).
- Mandar al usuario directamente a su empresa desde Go con
kc_idp_hint. - Qué pasa con roles, bajas, logout y cuentas que ya existían, probado paso a paso.
1. Keycloak como intermediario
| Federación LDAP (lección 11) | Brokering OIDC (esta lección) | |
|---|---|---|
| Quién ve la contraseña | Keycloak, que la comprueba contra el AD | Solo la empresa: Keycloak nunca la ve |
| Qué conexión hace falta | Keycloak → AD por LDAP (red interna) | Redirecciones del navegador y HTTPS de Keycloak a la empresa |
| Típico para | El AD de tu propia organización | Microsoft 365 / Entra ID, Google Workspace, otra empresa |
| Doble factor, política de contraseñas | Las de Keycloak | Las de la empresa |
2. La «empresa» de prueba
cd tienda/pasos/paso-11/infra && docker compose down -v
cd ../../paso-12/infra && docker compose up -d
El paso importa dos realms en el mismo Keycloak: tienda y corporativo, que hace el papel de Entra ID. Que sea el mismo servidor no cambia nada: tienda solo lo conoce por sus URLs, como conocería a Microsoft.
| Usuario de la empresa | Contraseña | Grupos en la empresa |
|---|---|---|
lucia (Lucía Marín, lucia@empresa.test) | Lucia-Empresa-2026! | tienda-compradores |
jorge (Jorge Paz, jorge@empresa.test) | Jorge-Empresa-2026! | tienda-compradores, tienda-admins |
En la empresa, la tienda es una aplicación registrada: el client tienda-broker. Su redirect URI es el endpoint de broker de Keycloak, /realms/tienda/broker/<alias>/endpoint, y un mapper añade los grupos al ID token. En Entra ID sería un App registration con esa misma URL.
{
"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": [
"http://localhost:8080/realms/tienda/broker/corporativo/endpoint"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8080/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"
}
}
]
}
3. El proveedor de identidad en el realm tienda
En la consola: Identity providers → OpenID Connect v1.0. Con la Discovery endpoint de la empresa, Keycloak rellena las URLs solo. En el realm del paso queda así:
{
"alias": "corporativo",
"displayName": "Cuenta de la empresa",
"providerId": "oidc",
"enabled": true,
"trustEmail": true,
"storeToken": false,
"firstBrokerLoginFlowAlias": "first broker login",
"config": {
"issuer": "http://localhost:8080/realms/corporativo",
"authorizationUrl": "http://localhost:8080/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"
}
}
| Opción | Valor | Por qué |
|---|---|---|
| Alias | corporativo | Aparece en la redirect URI y es lo que pide kc_idp_hint. |
| Issuer · Validate Signatures · Use JWKS URL | el de la empresa · On · On | Keycloak valida el ID token de la empresa como tu API valida los de Keycloak (lección 5). |
| Client ID / Secret | tienda-broker | Las credenciales de la tienda en la empresa. |
| PKCE | On, S256 | Keycloak es aquí un client OIDC: aplica lo mismo que tienda-web (lección 3). |
| Trust Email | On | El correo que dice la empresa se da por verificado. |
| Sync mode | FORCE | Los datos (y los roles de los mappers) se actualizan en cada login: la empresa manda (sección 6). |
| Backchannel logout | On | Al cerrar sesión en la tienda, Keycloak cierra también la de la empresa, de servidor a servidor. |
Mappers: quién es y qué puede hacer
{
"name": "admins → admin",
"identityProviderAlias": "corporativo",
"identityProviderMapper": "oidc-role-idp-mapper",
"config": {
"syncMode": "FORCE",
"claim": "groups",
"claim.value": "tienda-admins",
"role": "admin"
}
}
| Mapper | Tipo | Qué hace |
|---|---|---|
| usuario = email | Username Template Importer, ${CLAIM.email}, sync IMPORT | La cuenta local se llama como el correo (lucia@empresa.test): no choca con un usuario local lucia. Solo al crearla (ver «Errores comunes»). |
| compradores → cliente | Claim to Role, groups = tienda-compradores | Puede comprar. |
| admins → admin | Claim to Role, groups = tienda-admins | Puede entrar en Admin. |
Como en la lección 11, los nombres de los grupos de la empresa no se convierten en roles por sí solos: cada mapper traduce uno. Quien no esté en ningún grupo entra, pero sin cliente no puede comprar (en el ejercicio 2 lo dejas directamente fuera).
4. El cambio en Go: kc_idp_hint
Sin tocar nada, la página de login de Keycloak ya ofrece el botón «Sign in with Cuenta de la empresa». Pero es mejor que tienda-web tenga su propio botón y mande al usuario directamente a su empresa, sin pasar por una pantalla de usuario y contraseña que no va a usar. Para eso existe kc_idp_hint, un parámetro de la petición de autorización:
// handleLogin inicia el flujo: genera state, nonce y code_verifier y
// redirige el navegador a Keycloak. Con ?idp=alias (uno de los permitidos),
// Keycloak salta su página de login y va directo a ese proveedor de identidad.
func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
state := rand.Text()
nonce := rand.Text()
verifier := oauth2.GenerateVerifier()
a.mu.Lock()
a.dropExpiredLocked()
a.pending[state] = pendingLogin{
nonce: nonce,
verifier: verifier,
returnTo: safeReturnTo(r.URL.Query().Get("next")),
expiresAt: time.Now().Add(pendingTTL),
}
a.mu.Unlock()
// Guardamos el state también en una cookie: así solo ESTE navegador
// puede completar este login (protección contra login CSRF).
http.SetCookie(w, &http.Cookie{
Name: stateCookie,
Value: state,
Path: "/callback",
MaxAge: int(pendingTTL.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
opts := []oauth2.AuthCodeOption{oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier)}
if idp := r.URL.Query().Get("idp"); idp != "" && slices.Contains(a.idps, idp) {
opts = append(opts, oauth2.SetAuthURLParam("kc_idp_hint", idp))
}
authURL := a.oauth.AuthCodeURL(state, opts...)
http.Redirect(w, r, authURL, http.StatusFound)
}
La lista de proveedores permitidos (OIDC_IDP_HINTS, por defecto corporativo) evita que alguien construya enlaces /login?idp=… hacia proveedores que no quieres ofrecer desde esta app. En la plantilla basta un enlace:
<a class="btn ghost" href="/login?idp=corporativo">Cuenta de la empresa</a>
<a class="btn" href="/login">Entrar</a>
5. Probarlo
Arranca api, web y facturacion del paso 12 y pulsa «Cuenta de la empresa». Entra como lucia / Lucia-Empresa-2026! y compra algo. Su access token (salida real):
{
"iss": "http://localhost:8080/realms/tienda",
"sub": "be2dd43b-106e-4966-bf72-604e4be0499a",
"preferred_username": "lucia@empresa.test",
"name": "Lucía Marín",
"email": "lucia@empresa.test",
"email_verified": true,
"realm_access": { "roles": ["cliente", "offline_access", "uma_authorization", "default-roles-tienda"] },
"aud": ["facturacion", "api-pedidos", "account"]
}
El emisor es tienda, no la empresa: tus servicios siguen confiando en un único Keycloak. El sub es el de la cuenta local que Keycloak creó en el primer login, enlazada a la de la empresa:
Entra ahora como jorge: ve Admin, porque está en tienda-admins. api-pedidos no ha cambiado ni una línea.
admin-tool: de dónde viene cada cuenta
La columna ORIGEN de la lección 11 solo miraba federationLink, que usan los directorios LDAP. Las cuentas creadas por un proveedor de identidad no lo tienen: su vínculo está en federated identities. Users ahora lo consulta para las cuentas que parecían locales:
if info.Origin == "local" {
// Las cuentas creadas por un proveedor de identidad (lección 12) no
// tienen federationLink: su vínculo está en federated-identity.
links, err := a.kc.GetUserFederatedIdentities(ctx, a.token, a.realm, info.ID)
if err != nil {
return nil, explain(err)
}
if len(links) > 0 {
info.Origin = gocloak.PString(links[0].IdentityProvider)
}
}
go run ./cmd/admin usuarios
# USUARIO ORIGEN NOMBRE EMAIL ACTIVO ROLES GRUPOS
# ana local Ana Cliente ana@tienda.test true cliente
# carlos local Carlos Admin carlos@tienda.test true admin,cliente
# diego AD Diego Soporte diego@tienda.local true admin,cliente
# jorge@empresa.test corporativo Jorge Paz jorge@empresa.test true admin,cliente
# lucia@empresa.test corporativo Lucía Marín lucia@empresa.test true cliente
# sofia AD Sofia Ventas sofia@tienda.local true cliente
Las cuentas de la empresa solo aparecen después de su primer login: a diferencia de un directorio LDAP, un proveedor OIDC no se puede «sincronizar» por adelantado.
6. Quién manda (probado)
| Qué pasa | Resultado en la tienda |
|---|---|
La empresa saca a jorge de tienda-admins | En su siguiente login ya no es admin. Al devolverle el grupo, lo recupera. |
Un administrador de Keycloak da admin a lucia a mano | Lo pierde en su siguiente login: con FORCE, el mapper recalcula ese rol desde la empresa. |
| La empresa deshabilita a lucia | Un login nuevo falla, pero su sesión en la tienda sigue: el refresh devolvió 200. Dura hasta que caduca (30 min sin actividad; 10 h como máximo, el valor por defecto de Keycloak). |
| lucia cierra sesión en la tienda | Su sesión en la empresa también se cierra (pasó de 1 a 0), por back-channel. El siguiente login le pide la contraseña. |
| Alguien entra con el correo de una cuenta que ya existe | Keycloak muestra «Account already exists» y ofrece vincularla demostrando que es su dueño (ejercicio 3). |
Igual que con el AD, deshabilitar en el origen no echa a nadie que ya esté dentro. Para una baja urgente, cierra también sus sesiones en la tienda (admin expulsar, lección 9). Hay proveedores que avisan con un back-channel logout hacia Keycloak cuando cierran una sesión; no cubre las cuentas deshabilitadas.
7. Con Entra ID de verdad
Lo que sigue sale de la documentación de Keycloak y de Microsoft, no de una prueba con un tenant real. La mecánica es la de las secciones anteriores; cambian los datos.
- En Entra ID, App registrations → New registration, con redirect URI (Web)
https://tu-keycloak/realms/tienda/broker/entra/endpoint(entraes el alias que le darás). Crea un client secret. - En Keycloak, un proveedor OpenID Connect con la Discovery endpoint
https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration, el Application (client) ID y el secreto. - Los grupos llegan como GUID. El claim
groupsde Entra ID lleva por defecto los ID de objeto de los grupos, no sus nombres, y si un usuario está en más de 200 grupos no llega (en su lugar viene un claim de «overage»). Por eso suele ser mejor definir App roles en el App registration: llegan en el claimrolescon el nombre que les pongas, y el mapper Claim to Role queda igual que aquí, conrolesen lugar degroups. - Revisa qué claim trae el correo en tu tenant (
emailno siempre viene;preferred_usernamesuele ser el UPN) y ajusta el mapper de usuario.
Para tu código Go, nada de esto cambia: el botón sigue enviando kc_idp_hint=entra y los tokens los sigue emitiendo tu Keycloak.
Ejercicios
1. Solo el botón de la tienda · fácil
Haz que la página de login de Keycloak no muestre «Sign in with Cuenta de la empresa», pero que el botón de tienda-web siga funcionando.
Ver solución
Activa Hide on login page en el proveedor. Probado: el botón desaparece del formulario de Keycloak y /login?idp=corporativo sigue funcionando, porque kc_idp_hint no depende de él. Útil cuando cada empresa cliente tiene su propio enlace de entrada y no quieres una lista de logos para todos.
2. Que solo entren los compradores · media
Ahora cualquiera de la empresa puede entrar (sin cliente, pero entra). Haz que quien no esté en tienda-compradores ni siquiera tenga cuenta en la tienda.
Ver solución
En el proveedor, activa Verify essential claim con claim groups y valor tienda-compradores. Probado: funciona aunque groups sea una lista. Un usuario de la empresa sin ese grupo (creamos pedro) ve:
The ID token issued by the identity provider does not match the configured essential claim. Please contact your administrator.
lucia, que sí está en el grupo, entra como siempre. La diferencia con el mapper: el filtro deja a la persona fuera y Keycloak no le crea cuenta; el mapper solo decide sus roles.
3. ana también trabaja en la empresa · media
Crea en el realm corporativo un usuario ana.empresa con el correo de ana en la tienda (ana@tienda.test) y entra con él. ¿Qué pasa con su cuenta y con sus pedidos?
Ver solución
Keycloak detecta que ya existe una cuenta con ese correo y muestra Account already exists, con dos opciones: revisar el perfil o Add to existing account. Al elegir la segunda pide la contraseña de ana en la tienda (ana123): así nadie se apropia de una cuenta solo por controlar un correo en otro sitio. Tras vincularla, entrar por la empresa da el mismo sub que siempre (00000000-…-0000000000a1): conserva sus pedidos.
Probado, y con una sorpresa: ana perdió su rol cliente. Su cuenta de la empresa no está en tienda-compradores, y con FORCE el mapper se lo quitó, aunque se lo hubiéramos dado en la tienda. Si la empresa es la fuente de verdad de esos roles, es lo correcto; si no, añade a la persona al grupo en la empresa o usa un modo de sincronización que no los recalcule.
Errores comunes
Unexpected error when authenticating with identity provider
El mensaje genérico de casi cualquier fallo entre Keycloak y la empresa. Mira el log de Keycloak: con un secreto incorrecto, por ejemplo, dice Unexpected response from token endpoint … status=401. Otras causas: el Token URL no es alcanzable desde el servidor de Keycloak (no desde tu navegador) o el issuer no coincide.
el secreto dejó de funcionar tras actualizar el proveedor por la Admin API
Al leer el proveedor (GET …/identity-provider/instances/corporativo) el secreto viene enmascarado como **********. Si devuelves ese objeto con un PUT, guardas los asteriscos como secreto. Nos pasó al preparar esta lección. Pon el secreto real en config.clientSecret antes del PUT; es la misma familia de problema que la contraseña de bind de la lección 11.
Invalid parameter: redirect_uri en la página de la empresa
La redirect URI registrada en la empresa no coincide con …/realms/tienda/broker/<alias>/endpoint. Si cambias el alias, cambia también la URI registrada.
ana ya no puede entrar como «ana» tras vincular su cuenta
Probado en la primera versión de este paso: el mapper de usuario estaba en FORCE y, al vincular, renombró la cuenta local a ana@tienda.test. Por eso el realm usa IMPORT en ese mapper: la plantilla solo se aplica al crear cuentas nuevas.
perdió el rol cliente al entrar por la empresa
El mapper Claim to Role en FORCE quita el rol si el claim no lo justifica, incluso a cuentas locales vinculadas (ejercicio 3). Decide quién manda en cada rol: la empresa (FORCE) o la tienda.
kc_idp_hint no hace nada sale el login normal
El alias no existe o está mal escrito: Keycloak lo ignora y muestra su formulario (probado con kc_idp_hint=noexiste). En tienda-web, comprueba también OIDC_IDP_HINTS: un alias fuera de la lista no se envía.
Keycloak puede delegar quién es cada persona en un directorio (LDAP/AD, lección 11) o en otro proveedor de identidad (OIDC, esta lección), y traducir sus grupos a roles de la tienda. Tus servicios Go siguen confiando en un solo emisor. Lo que sí debes decidir, y lo has visto probado, es quién manda en cada dato y qué pasa con las sesiones abiertas cuando alguien se va.