Qué cambió — paso 12
Qué cambió · paso-11 → paso-12

Cambios de la lección 12

Todo lo que cambia en tienda/pasos/paso-12 respecto al paso anterior. Vuelve a la lección: 12. Entrar con la cuenta de la empresa.

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

ArchivoEstadoLíneas
cmd/discover/main.goeliminado+0 −110
cmd/web/main.gomodificado+3 −0
infra/realm/corporativo-realm.jsonnuevo+91 −0
infra/realm/tienda-realm.jsonmodificado+62 −0
internal/admin/admin.gomodificado+12 −1
internal/auth/auth.gomodificado+14 −5
internal/web/templates/layout.htmlmodificado+1 −0
cmd/discover/main.go eliminado · +0 −110
@@ -1,110 +0,0 @@
-// Command discover consulta el documento de descubrimiento OIDC de un realm
-// de Keycloak y muestra los endpoints y las claves públicas (JWKS).
-//
-// Uso:
-//
-//	go run ./cmd/discover
-//	go run ./cmd/discover -issuer http://localhost:8080/realms/tienda
-package main
-
-import (
-	"context"
-	"encoding/json"
-	"flag"
-	"fmt"
-	"log"
-	"net/http"
-	"os"
-	"strings"
-	"time"
-)
-
-// discovery contiene los campos del documento
-// /.well-known/openid-configuration que nos interesan en el curso.
-type discovery struct {
-	Issuer                        string   `json:"issuer"`
-	AuthorizationEndpoint         string   `json:"authorization_endpoint"`
-	TokenEndpoint                 string   `json:"token_endpoint"`
-	UserinfoEndpoint              string   `json:"userinfo_endpoint"`
-	EndSessionEndpoint            string   `json:"end_session_endpoint"`
-	IntrospectionEndpoint         string   `json:"introspection_endpoint"`
-	JWKSURI                       string   `json:"jwks_uri"`
-	GrantTypesSupported           []string `json:"grant_types_supported"`
-	CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
-	IDTokenSigningAlgs            []string `json:"id_token_signing_alg_values_supported"`
-}
-
-// jwks es el conjunto de claves públicas con las que Keycloak firma los tokens.
-type jwks struct {
-	Keys []struct {
-		Kid string `json:"kid"`
-		Kty string `json:"kty"`
-		Alg string `json:"alg"`
-		Use string `json:"use"`
-	} `json:"keys"`
-}
-
-func main() {
-	issuer := flag.String("issuer", "http://localhost:8080/realms/tienda", "URL del realm (issuer)")
-	flag.Parse()
-
-	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-	defer cancel()
-
-	client := &http.Client{Timeout: 5 * time.Second}
-
-	var doc discovery
-	wellKnown := strings.TrimSuffix(*issuer, "/") + "/.well-known/openid-configuration"
-	if err := getJSON(ctx, client, wellKnown, &doc); err != nil {
-		log.Fatalf("descubrimiento: %v", err)
-	}
-
-	// OIDC exige que el issuer del documento sea idéntico al que usamos para pedirlo.
-	// Si no coincide (por ejemplo, localhost frente a 127.0.0.1), las librerías
-	// de validación rechazarán los tokens más adelante.
-	if doc.Issuer != *issuer {
-		fmt.Fprintf(os.Stderr, "AVISO: el issuer del documento (%s) no coincide con %s\n", doc.Issuer, *issuer)
-	}
-
-	fmt.Println("== Endpoints del realm ==")
-	fmt.Printf("issuer:        %s\n", doc.Issuer)
-	fmt.Printf("authorization: %s\n", doc.AuthorizationEndpoint)
-	fmt.Printf("token:         %s\n", doc.TokenEndpoint)
-	fmt.Printf("userinfo:      %s\n", doc.UserinfoEndpoint)
-	fmt.Printf("end_session:   %s\n", doc.EndSessionEndpoint)
-	fmt.Printf("introspection: %s\n", doc.IntrospectionEndpoint)
-	fmt.Printf("jwks_uri:      %s\n", doc.JWKSURI)
-	fmt.Printf("grant types:   %s\n", strings.Join(doc.GrantTypesSupported, ", "))
-	fmt.Printf("PKCE:          %s\n", strings.Join(doc.CodeChallengeMethodsSupported, ", "))
-	fmt.Printf("firmas ID tok: %s\n", strings.Join(doc.IDTokenSigningAlgs, ", "))
-
-	var keys jwks
-	if err := getJSON(ctx, client, doc.JWKSURI, &keys); err != nil {
-		log.Fatalf("jwks: %v", err)
-	}
-
-	fmt.Println("\n== Claves públicas (JWKS) ==")
-	for _, k := range keys.Keys {
-		fmt.Printf("kid=%s  kty=%s  alg=%s  use=%s\n", k.Kid, k.Kty, k.Alg, k.Use)
-	}
-}
-
-// getJSON hace un GET a url y decodifica la respuesta JSON en v.
-func getJSON(ctx context.Context, client *http.Client, url string, v any) error {
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
-	if err != nil {
-		return err
-	}
-	req.Header.Set("Accept", "application/json")
-
-	resp, err := client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("GET %s: estado %s", url, resp.Status)
-	}
-	return json.NewDecoder(resp.Body).Decode(v)
-}
cmd/web/main.go modificado · +3 −0
@@ -29,6 +29,9 @@
 		RedirectURL:  env("OIDC_REDIRECT_URL", "http://localhost:3000/callback"),
 
 		PostLogoutRedirectURL: env("OIDC_POST_LOGOUT_URL", "http://localhost:3000/"),
+
+		// Proveedores de identidad a los que se puede entrar directamente (lección 12).
+		IdentityProviders: strings.Fields(env("OIDC_IDP_HINTS", "corporativo")),
 	}
 	addr := env("ADDR", ":3000")
 	apiURL := env("API_URL", "http://localhost:8081")              // api-pedidos
infra/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": [
+        "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"
+          }
+        }
+      ]
+    }
+  ]
+}
infra/realm/tienda-realm.json modificado · +62 −0 · generado, plegado
@@ -584,6 +584,68 @@
       }
     ]
   },
+  "identityProviders": [
+    {
+      "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"
+      }
+    }
+  ],
+  "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"
+      }
+    }
+  ],
   "clientScopes": [
     {
       "name": "api-pedidos",
internal/admin/admin.go modificado · +12 −1
@@ -56,7 +56,7 @@
 type UserInfo struct {
 	ID, Username, Email, Name string
 	Enabled                   bool
-	Origin                    string   // "AD" si viene de un directorio (federationLink), si no "local"
+	Origin                    string   // "AD", el alias del proveedor de identidad (lección 12) o "local"
 	Roles                     []string // roles de realm asignados directamente
 	Groups                    []string
 }
@@ -79,6 +79,17 @@
 			Name:     gocloak.PString(u.FirstName) + " " + gocloak.PString(u.LastName),
 			Enabled:  gocloak.PBool(u.Enabled),
 			Origin:   origin(u),
+		}
+		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)
+			}
 		}
 		roles, err := a.kc.GetRealmRolesByUserID(ctx, a.token, a.realm, info.ID)
 		if err != nil {
internal/auth/auth.go modificado · +14 −5
@@ -11,6 +11,7 @@
 	"log"
 	"net/http"
 	"net/url"
+	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -40,6 +41,10 @@
 	ClientSecret          string
 	RedirectURL           string // debe estar en «Valid redirect URIs» del client
 	PostLogoutRedirectURL string // debe estar en «Valid post logout redirect URIs»
+
+	// IdentityProviders son los alias de proveedores de identidad de Keycloak
+	// a los que /login?idp=… puede saltar directamente (kc_idp_hint).
+	IdentityProviders []string
 }
 
 // pendingLogin es lo que recordamos entre /login y /callback.
@@ -57,6 +62,7 @@
 	verifier      *oidc.IDTokenVerifier
 	sessions      *session.Store
 	endSessionURL string // end_session_endpoint del descubrimiento
+	idps          []string
 	postLogoutURL string
 
 	mu      sync.Mutex
@@ -94,6 +100,7 @@
 		sessions:      sessions,
 		endSessionURL: meta.EndSessionEndpoint,
 		postLogoutURL: cfg.PostLogoutRedirectURL,
+		idps:          cfg.IdentityProviders,
 		pending:       make(map[string]pendingLogin),
 	}, nil
 }
@@ -107,7 +114,8 @@
 }
 
 // handleLogin inicia el flujo: genera state, nonce y code_verifier y
-// redirige el navegador a Keycloak.
+// 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()
@@ -134,10 +142,11 @@
 		SameSite: http.SameSiteLaxMode,
 	})
 
-	authURL := a.oauth.AuthCodeURL(state,
-		oidc.Nonce(nonce),
-		oauth2.S256ChallengeOption(verifier),
-	)
+	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)
 }
 
internal/web/templates/layout.html modificado · +1 −0
@@ -50,6 +50,7 @@
       <span class="who">Hola, <strong>{{.Session.User.Name}}</strong></span>
       <form method="post" action="/logout"><button class="btn ghost" type="submit">Salir</button></form>
     {{else}}
+      <a class="btn ghost" href="/login?idp=corporativo">Cuenta de la empresa</a>
       <a class="btn" href="/login">Entrar</a>
     {{end}}
   </div>

← Volver a la lección 12