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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/discover/main.go | eliminado | +0 −110 |
cmd/web/main.go | modificado | +3 −0 |
infra/realm/corporativo-realm.json | nuevo | +91 −0 |
infra/realm/tienda-realm.json | modificado | +62 −0 |
internal/admin/admin.go | modificado | +12 −1 |
internal/auth/auth.go | modificado | +14 −5 |
internal/web/templates/layout.html | modificado | +1 −0 |
cmd/discover/main.go
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>