Módulo 6 · Lección 12

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.

≈ 55 min Carpeta: tienda/pasos/paso-12 Realm nuevo: hay que reimportar

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 -v en el paso anterior
Archivos
Nuevo: infra/realm/corporativo-realm.json (la «empresa»); cambian el realm tienda, internal/auth/auth.go, cmd/web/main.go, la plantilla layout.html e internal/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 (desde course/)

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

tienda-web manda al usuario a Keycloak, Keycloak lo manda a la empresa, la empresa lo autentica y devuelve un ID token, y Keycloak emite sus propios tokens para la tienda tienda-web /login?idp=corporativo Keycloak · tienda el broker emite los tokens de la tienda La empresa Entra ID · aquí: realm corporativo 1. kc_idp_hint 2. login en la empresa 3. ID token de la empresa 4. tokens de la tienda Keycloak valida el ID token de la empresa (firma, issuer), crea o actualiza la cuenta local de lucia, aplica los mappers (grupos → roles) y emite tokens con iss = …/realms/tienda. api-pedidos no nota la diferencia.
Para Keycloak, la empresa es un proveedor OpenID Connect; para la empresa, Keycloak es una aplicación más.
Federación LDAP (lección 11)Brokering OIDC (esta lección)
Quién ve la contraseñaKeycloak, que la comprueba contra el ADSolo la empresa: Keycloak nunca la ve
Qué conexión hace faltaKeycloak → AD por LDAP (red interna)Redirecciones del navegador y HTTPS de Keycloak a la empresa
Típico paraEl AD de tu propia organizaciónMicrosoft 365 / Entra ID, Google Workspace, otra empresa
Doble factor, política de contraseñasLas de KeycloakLas 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 empresaContraseñaGrupos 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"
  }
}
Ajustes OpenID Connect del proveedor: Authorization URL, Token URL, Issuer, Validate Signatures
Identity providers → Cuenta de la empresa: las URLs de la empresa y el issuer que Keycloak exigirá en sus tokens.
OpciónValorPor qué
AliascorporativoAparece en la redirect URI y es lo que pide kc_idp_hint.
Issuer · Validate Signatures · Use JWKS URLel de la empresa · On · OnKeycloak valida el ID token de la empresa como tu API valida los de Keycloak (lección 5).
Client ID / Secrettienda-brokerLas credenciales de la tienda en la empresa.
PKCEOn, S256Keycloak es aquí un client OIDC: aplica lo mismo que tienda-web (lección 3).
Trust EmailOnEl correo que dice la empresa se da por verificado.
Sync modeFORCELos datos (y los roles de los mappers) se actualizan en cada login: la empresa manda (sección 6).
Backchannel logoutOnAl 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

Mappers del proveedor: usuario = email, compradores → cliente, admins → admin
Tres mappers: el nombre de usuario y dos reglas «grupo de la empresa → rol de la tienda».
{
  "name": "admins → admin",
  "identityProviderAlias": "corporativo",
  "identityProviderMapper": "oidc-role-idp-mapper",
  "config": {
    "syncMode": "FORCE",
    "claim": "groups",
    "claim.value": "tienda-admins",
    "role": "admin"
  }
}
MapperTipoQué hace
usuario = emailUsername Template Importer, ${CLAIM.email}, sync IMPORTLa cuenta local se llama como el correo (lucia@empresa.test): no choca con un usuario local lucia. Solo al crearla (ver «Errores comunes»).
compradores → clienteClaim to Role, groups = tienda-compradoresPuede comprar.
admins → adminClaim to Role, groups = tienda-adminsPuede 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>
Login de Keycloak de la tienda con el botón Sign in with Cuenta de la empresa
«Entrar»: el login de la tienda, con el botón del proveedor debajo.
Login del realm corporativo: Empresa S.A. (simula Entra ID)
«Cuenta de la empresa»: directo al login de la empresa.

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:

Perfil de tienda-web con los claims de Lucía Marín
El perfil de lucia en tienda-web: los mismos claims de siempre.
Identity provider links de lucia@empresa.test: Corporativo, usuario lucia
Users → lucia@empresa.test → Identity provider links.

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é pasaResultado en la tienda
La empresa saca a jorge de tienda-adminsEn su siguiente login ya no es admin. Al devolverle el grupo, lo recupera.
Un administrador de Keycloak da admin a lucia a manoLo pierde en su siguiente login: con FORCE, el mapper recalcula ese rol desde la empresa.
La empresa deshabilita a luciaUn 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 tiendaSu 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 existeKeycloak muestra «Account already exists» y ofrece vincularla demostrando que es su dueño (ejercicio 3).
Bajas: la empresa no puede cerrar tu sesión

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

Sin probar en este curso

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.

  1. En Entra ID, App registrations → New registration, con redirect URI (Web) https://tu-keycloak/realms/tienda/broker/entra/endpoint (entra es el alias que le darás). Crea un client secret.
  2. 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.
  3. Los grupos llegan como GUID. El claim groups de 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 claim roles con el nombre que les pongas, y el mapper Claim to Role queda igual que aquí, con roles en lugar de groups.
  4. Revisa qué claim trae el correo en tu tenant (email no siempre viene; preferred_username suele 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.

Resumen del módulo 6

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.