Qué cambió — paso 04
Qué cambió · paso-03 → paso-04

Cambios de la lección 04

Todo lo que cambia en tienda/pasos/paso-04 respecto al paso anterior. Vuelve a la lección: 04. Sesión, refresh y logout.

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

ArchivoEstadoLíneas
cmd/web/main.gomodificado+2 −0
internal/auth/auth.gomodificado+141 −18
internal/session/session.gomodificado+46 −7
internal/web/templates/layout.htmlmodificado+1 −0
internal/web/templates/perfil.htmlmodificado+25 −1
internal/web/web.gomodificado+58 −2
cmd/web/main.go modificado · +2 −0
@@ -25,6 +25,8 @@
 		ClientID:     env("OIDC_CLIENT_ID", "tienda-web"),
 		ClientSecret: env("OIDC_CLIENT_SECRET", "tienda-web-secret"), // solo para desarrollo
 		RedirectURL:  env("OIDC_REDIRECT_URL", "http://localhost:3000/callback"),
+
+		PostLogoutRedirectURL: env("OIDC_POST_LOGOUT_URL", "http://localhost:3000/"),
 	}
 	addr := env("ADDR", ":3000")
 
internal/auth/auth.go modificado · +141 −18
@@ -1,10 +1,12 @@
 // Package auth implementa el login de tienda-web contra Keycloak con
-// OpenID Connect: Authorization Code + PKCE, usando go-oidc y x/oauth2.
+// OpenID Connect: Authorization Code + PKCE, renovación de tokens y logout
+// (iniciado por la app y back-channel), usando go-oidc y x/oauth2.
 package auth
 
 import (
 	"context"
 	"crypto/rand"
+	"errors"
 	"fmt"
 	"log"
 	"net/http"
@@ -23,14 +25,21 @@
 	sessionCookie = "tienda_session" // ID de la sesión de la app
 	stateCookie   = "tienda_state"   // ata el login en curso a este navegador
 	pendingTTL    = 10 * time.Minute // tiempo máximo para completar el login
+
+	backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
 )
+
+// ErrSessionEnded indica que la sesión SSO ya no existe en Keycloak (caducó,
+// se cerró desde otro sitio o la revocó un administrador): hay que volver a entrar.
+var ErrSessionEnded = errors.New("la sesión en Keycloak ha terminado")
 
 // Config es lo que tienda-web necesita saber de su client en Keycloak.
 type Config struct {
-	Issuer       string // URL del realm, p. ej. http://localhost:8080/realms/tienda
-	ClientID     string
-	ClientSecret string
-	RedirectURL  string // debe estar en «Valid redirect URIs» del client
+	Issuer                string // URL del realm, p. ej. http://localhost:8080/realms/tienda
+	ClientID              string
+	ClientSecret          string
+	RedirectURL           string // debe estar en «Valid redirect URIs» del client
+	PostLogoutRedirectURL string // debe estar en «Valid post logout redirect URIs»
 }
 
 // pendingLogin es lo que recordamos entre /login y /callback.
@@ -41,11 +50,14 @@
 	expiresAt time.Time
 }
 
-// Auth agrupa la configuración OIDC y los handlers de login.
+// Auth agrupa la configuración OIDC y los handlers de login y logout.
 type Auth struct {
-	oauth    oauth2.Config
-	verifier *oidc.IDTokenVerifier
-	sessions *session.Store
+	oauth         oauth2.Config
+	provider      *oidc.Provider
+	verifier      *oidc.IDTokenVerifier
+	sessions      *session.Store
+	endSessionURL string // end_session_endpoint del descubrimiento
+	postLogoutURL string
 
 	mu      sync.Mutex
 	pending map[string]pendingLogin // clave: state
@@ -56,6 +68,14 @@
 	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
 	if err != nil {
 		return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", cfg.Issuer, err)
+	}
+
+	// go-oidc no expone end_session_endpoint como campo: lo leemos de los claims del descubrimiento.
+	var meta struct {
+		EndSessionEndpoint string `json:"end_session_endpoint"`
+	}
+	if err := provider.Claims(&meta); err != nil {
+		return nil, fmt.Errorf("leyendo el descubrimiento: %w", err)
 	}
 
 	return &Auth{
@@ -66,10 +86,14 @@
 			Endpoint:     provider.Endpoint(), // URLs de /auth y /token, sacadas del descubrimiento
 			Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
 		},
+		provider: provider,
 		// El verificador comprueba firma (con el JWKS), iss, aud == ClientID y exp.
-		verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
-		sessions: sessions,
-		pending:  make(map[string]pendingLogin),
+		// Sirve igual para los ID tokens y para los logout tokens.
+		verifier:      provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
+		sessions:      sessions,
+		endSessionURL: meta.EndSessionEndpoint,
+		postLogoutURL: cfg.PostLogoutRedirectURL,
+		pending:       make(map[string]pendingLogin),
 	}, nil
 }
 
@@ -78,6 +102,7 @@
 	mux.HandleFunc("GET /login", a.handleLogin)
 	mux.HandleFunc("GET /callback", a.handleCallback)
 	mux.HandleFunc("POST /logout", a.handleLogout)
+	mux.HandleFunc("POST /backchannel-logout", a.handleBackchannelLogout)
 }
 
 // handleLogin inicia el flujo: genera state, nonce y code_verifier y
@@ -171,11 +196,12 @@
 		return
 	}
 
-	// 6. Leemos los claims que nos interesan y creamos la sesión.
+	// 6. Leemos los claims que nos interesan y creamos la sesión, ahora con los tokens.
 	var claims struct {
 		Username string `json:"preferred_username"`
 		Name     string `json:"name"`
 		Email    string `json:"email"`
+		SID      string `json:"sid"`
 	}
 	var all map[string]any
 	if err := idToken.Claims(&claims); err != nil {
@@ -192,7 +218,7 @@
 		Username: claims.Username,
 		Name:     claims.Name,
 		Email:    claims.Email,
-	}, all, rawIDToken)
+	}, all, rawIDToken, claims.SID, tok)
 
 	http.SetCookie(w, &http.Cookie{
 		Name:     sessionCookie,
@@ -205,14 +231,106 @@
 	http.Redirect(w, r, p.returnTo, http.StatusFound)
 }
 
-// handleLogout cierra la sesión LOCAL de tienda-web.
-// Ojo: la sesión SSO en Keycloak sigue viva (lo arreglamos en la lección 4).
+// Token devuelve un access token válido para la sesión. Si el actual ha
+// caducado (o force es true), lo renueva con el refresh token y guarda los
+// tokens nuevos en la sesión. Si Keycloak ya no reconoce la sesión, borra la
+// sesión local y devuelve ErrSessionEnded.
+func (a *Auth) Token(ctx context.Context, sess *session.Session, force bool) (*oauth2.Token, error) {
+	current := *sess.Token
+	if force {
+		current.Expiry = time.Now().Add(-time.Second) // hacemos creer a x/oauth2 que caducó
+	}
+
+	// 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, &current).Token()
+	if err != nil {
+		var re *oauth2.RetrieveError
+		if errors.As(err, &re) && re.ErrorCode == "invalid_grant" {
+			log.Printf("refresh rechazado (%s): cerrando la sesión local", re.ErrorDescription)
+			a.sessions.Delete(sess.ID)
+			return nil, ErrSessionEnded
+		}
+		return nil, fmt.Errorf("renovando el token: %w", err)
+	}
+
+	if tok.AccessToken != sess.Token.AccessToken {
+		rawIDToken, _ := tok.Extra("id_token").(string)
+		a.sessions.UpdateTokens(sess.ID, tok, rawIDToken)
+	}
+	return tok, nil
+}
+
+// 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))
+	if err != nil {
+		return nil, err
+	}
+	var claims map[string]any
+	err = ui.Claims(&claims)
+	return claims, err
+}
+
+// handleLogout cierra la sesión local y, después, la sesión SSO en Keycloak
+// (RP-initiated logout): redirige al end_session_endpoint con el ID token
+// como pista, y Keycloak devuelve al usuario a PostLogoutRedirectURL.
 func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
+	var idToken string
 	if c, err := r.Cookie(sessionCookie); err == nil {
+		if sess, ok := a.sessions.Get(c.Value); ok {
+			idToken = sess.IDToken
+		}
 		a.sessions.Delete(c.Value)
 	}
 	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1})
-	http.Redirect(w, r, "/", http.StatusSeeOther)
+
+	if idToken == "" || a.endSessionURL == "" {
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+		return
+	}
+	v := url.Values{
+		"id_token_hint":            {idToken}, // sin él, Keycloak pide confirmación
+		"post_logout_redirect_uri": {a.postLogoutURL},
+	}
+	http.Redirect(w, r, a.endSessionURL+"?"+v.Encode(), http.StatusSeeOther)
+}
+
+// handleBackchannelLogout recibe el aviso de Keycloak cuando una sesión SSO
+// termina en otro sitio (otra app, la consola de cuenta, un administrador…).
+// Keycloak lo envía servidor a servidor: un POST con un logout_token firmado.
+func (a *Auth) handleBackchannelLogout(w http.ResponseWriter, r *http.Request) {
+	raw := r.PostFormValue("logout_token")
+	if raw == "" {
+		http.Error(w, "falta logout_token", http.StatusBadRequest)
+		return
+	}
+	tok, err := a.verifier.Verify(r.Context(), raw) // firma, iss, aud y exp
+	if err != nil {
+		log.Printf("back-channel logout: token inválido: %v", err)
+		http.Error(w, "logout_token inválido", http.StatusBadRequest)
+		return
+	}
+
+	var claims struct {
+		SID    string         `json:"sid"`
+		Nonce  string         `json:"nonce"`
+		Events map[string]any `json:"events"`
+	}
+	if err := tok.Claims(&claims); err != nil {
+		http.Error(w, "claims ilegibles", http.StatusBadRequest)
+		return
+	}
+	// Un logout token DEBE traer el evento de logout y NO puede traer nonce:
+	// así nadie puede colarnos un ID token como si fuera un logout token.
+	if _, ok := claims.Events[backchannelLogoutEvent]; !ok || claims.Nonce != "" || claims.SID == "" {
+		http.Error(w, "no es un logout token válido", http.StatusBadRequest)
+		return
+	}
+
+	n := a.sessions.DeleteBySID(claims.SID)
+	log.Printf("back-channel logout: sid=%s, %d sesión(es) cerrada(s)", claims.SID, n)
+	w.WriteHeader(http.StatusOK)
 }
 
 // CurrentSession devuelve la sesión del usuario de esta petición, si la hay.
@@ -228,11 +346,16 @@
 func (a *Auth) RequireLogin(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if _, ok := a.CurrentSession(r); !ok {
-			http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
+			http.Redirect(w, r, LoginURL(r), http.StatusFound)
 			return
 		}
 		next.ServeHTTP(w, r)
 	})
+}
+
+// LoginURL es la ruta de login que vuelve a la página actual.
+func LoginURL(r *http.Request) string {
+	return "/login?next=" + url.QueryEscape(r.URL.RequestURI())
 }
 
 // dropExpiredLocked borra logins a medio hacer que ya caducaron.
internal/session/session.go modificado · +46 −7
@@ -1,7 +1,7 @@
 // Package session guarda en memoria las sesiones de los usuarios de tienda-web.
 //
 // El navegador solo recibe una cookie con un ID aleatorio; los datos del
-// usuario (y, en la lección 4, los tokens) se quedan en el servidor.
+// usuario y sus tokens se quedan en el servidor.
 // Al reiniciar el proceso se pierden todas las sesiones: suficiente para el
 // curso. En producción usarías Redis, una base de datos o similar.
 package session
@@ -10,6 +10,8 @@
 	"crypto/rand"
 	"sync"
 	"time"
+
+	"golang.org/x/oauth2"
 )
 
 // User es la identidad del usuario, sacada del ID token ya verificado.
@@ -25,11 +27,15 @@
 	ID        string
 	User      User
 	Claims    map[string]any // todos los claims del ID token, para la página /perfil
-	IDToken   string         // el ID token en bruto; lo usaremos para el logout (lección 4)
+	IDToken   string         // el ID token en bruto: id_token_hint del logout
+	SID       string         // claim "sid": ID de la sesión SSO en Keycloak
+	Token     *oauth2.Token  // access token, refresh token y caducidad
+	Refreshes int            // cuántas veces se ha renovado el access token
 	ExpiresAt time.Time
 }
 
 // Store es un almacén de sesiones en memoria, seguro para uso concurrente.
+// Get devuelve copias: para cambiar una sesión hay que usar los métodos del Store.
 type Store struct {
 	mu       sync.Mutex
 	ttl      time.Duration
@@ -41,22 +47,25 @@
 	return &Store{ttl: ttl, sessions: make(map[string]*Session)}
 }
 
-// Create guarda una sesión nueva con un ID aleatorio y la devuelve.
-func (s *Store) Create(u User, claims map[string]any, rawIDToken string) *Session {
+// Create guarda una sesión nueva con un ID aleatorio y devuelve una copia.
+func (s *Store) Create(u User, claims map[string]any, rawIDToken, sid string, tok *oauth2.Token) *Session {
 	sess := &Session{
 		ID:        rand.Text(), // 128 bits aleatorios (Go 1.24+)
 		User:      u,
 		Claims:    claims,
 		IDToken:   rawIDToken,
+		SID:       sid,
+		Token:     tok,
 		ExpiresAt: time.Now().Add(s.ttl),
 	}
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	s.sessions[sess.ID] = sess
-	return sess
+	c := *sess
+	return &c
 }
 
-// Get devuelve la sesión si existe y no ha caducado.
+// Get devuelve una copia de la sesión si existe y no ha caducado.
 func (s *Store) Get(id string) (*Session, bool) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
@@ -68,7 +77,22 @@
 		delete(s.sessions, id)
 		return nil, false
 	}
-	return sess, true
+	c := *sess
+	return &c, true
+}
+
+// UpdateTokens guarda los tokens renovados. Keycloak devuelve también un
+// ID token nuevo, que conviene usar como id_token_hint en el logout.
+func (s *Store) UpdateTokens(id string, tok *oauth2.Token, rawIDToken string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if sess, ok := s.sessions[id]; ok {
+		sess.Token = tok
+		sess.Refreshes++
+		if rawIDToken != "" {
+			sess.IDToken = rawIDToken
+		}
+	}
 }
 
 // Delete elimina la sesión (logout).
@@ -77,3 +101,18 @@
 	defer s.mu.Unlock()
 	delete(s.sessions, id)
 }
+
+// DeleteBySID elimina todas las sesiones ligadas a una sesión SSO de Keycloak
+// (back-channel logout) y devuelve cuántas borró.
+func (s *Store) DeleteBySID(sid string) int {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	n := 0
+	for id, sess := range s.sessions {
+		if sess.SID == sid {
+			delete(s.sessions, id)
+			n++
+		}
+	}
+	return n
+}
internal/web/templates/layout.html modificado · +1 −0
@@ -19,6 +19,7 @@
     .btn.ghost { background:transparent; color:var(--text); border:1px solid var(--line); }
     main { max-width:960px; margin:0 auto; padding:1.5rem 1rem 3rem; }
     h1 { margin:.2rem 0 1rem; }
+    h2 { font-size:1.15rem; margin:1.6rem 0 .6rem; }
     .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:1rem; }
     .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:1rem; }
     .card h3 { margin:0 0 .3rem; font-size:1.05rem; }
internal/web/templates/perfil.html modificado · +25 −1
@@ -1,6 +1,30 @@
 {{define "content"}}
 <h1>Perfil</h1>
-<p>Estos son los claims del <strong>ID token</strong> verificado con el que se creó tu sesión.</p>
+{{if .Error}}<div class="note">⚠ {{.Error}}</div>{{end}}
+
+<h2>Tokens de la sesión</h2>
+<table>
+  <tbody>
+    <tr><td>Access token caduca en</td><td><strong>{{.Token.AccessExpiresIn}}</strong></td></tr>
+    <tr><td>Refresh token</td><td>{{if .Token.HasRefresh}}sí (guardado en el servidor){{else}}no{{end}}</td></tr>
+    <tr><td>Renovaciones desde el login</td><td>{{.Token.Refreshes}}</td></tr>
+  </tbody>
+</table>
+<form method="post" action="/perfil/renovar" style="margin:.8rem 0 1.5rem">
+  <button class="btn" type="submit">Renovar ahora</button>
+</form>
+
+<h2>Userinfo <span class="badge">llamada en vivo con el access token</span></h2>
+<table>
+  <thead><tr><th>Claim</th><th>Valor</th></tr></thead>
+  <tbody>
+  {{range .UserInfo}}
+    <tr><td><code>{{.Name}}</code></td><td><code>{{.Value}}</code></td></tr>
+  {{end}}
+  </tbody>
+</table>
+
+<h2>ID token <span class="badge">del login</span></h2>
 <table>
   <thead><tr><th>Claim</th><th>Valor</th></tr></thead>
   <tbody>
internal/web/web.go modificado · +58 −2
@@ -4,10 +4,12 @@
 import (
 	"embed"
 	"encoding/json"
+	"errors"
 	"html/template"
 	"log"
 	"net/http"
 	"sort"
+	"time"
 
 	"tienda/internal/auth"
 	"tienda/internal/session"
@@ -55,6 +57,13 @@
 	Value string
 }
 
+// tokenInfo resume el estado de los tokens de la sesión para /perfil.
+type tokenInfo struct {
+	AccessExpiresIn time.Duration // cuánto le queda al access token
+	Refreshes       int           // renovaciones desde el login
+	HasRefresh      bool
+}
+
 // pageData es lo que reciben todas las plantillas.
 type pageData struct {
 	Active   string // pestaña activa del menú
@@ -62,6 +71,9 @@
 	Products []Product
 	Orders   []Order
 	Claims   []claim
+	UserInfo []claim
+	Token    tokenInfo
+	Error    string
 }
 
 type handlers struct {
@@ -79,6 +91,7 @@
 	mux.HandleFunc("GET /{$}", h.home)
 	mux.Handle("GET /pedidos", a.RequireLogin(http.HandlerFunc(h.pedidos)))
 	mux.Handle("GET /perfil", a.RequireLogin(http.HandlerFunc(h.perfil)))
+	mux.Handle("POST /perfil/renovar", a.RequireLogin(http.HandlerFunc(h.renovar)))
 }
 
 func (h *handlers) home(w http.ResponseWriter, r *http.Request) {
@@ -95,15 +108,58 @@
 	})
 }
 
+// perfil muestra los claims del ID token, el estado de los tokens y los datos
+// que devuelve userinfo: una llamada real a Keycloak con el access token.
 func (h *handlers) perfil(w http.ResponseWriter, r *http.Request) {
 	sess, _ := h.auth.CurrentSession(r)
+
+	// Token renueva el access token si ha caducado.
+	tok, err := h.auth.Token(r.Context(), sess, false)
+	if errors.Is(err, auth.ErrSessionEnded) {
+		http.Redirect(w, r, auth.LoginURL(r), http.StatusFound)
+		return
+	}
+	data := pageData{Active: "perfil", Claims: sortedClaims(sess.Claims)}
+	if err != nil {
+		data.Error = err.Error()
+	} else if ui, err := h.auth.UserInfo(r.Context(), tok); err != nil {
+		data.Error = "userinfo: " + err.Error()
+	} else {
+		data.UserInfo = sortedClaims(ui)
+	}
+
+	sess, _ = h.auth.CurrentSession(r) // releemos: Token pudo actualizarla
+	data.Session = sess
+	data.Token = tokenInfo{
+		AccessExpiresIn: time.Until(sess.Token.Expiry).Round(time.Second),
+		Refreshes:       sess.Refreshes,
+		HasRefresh:      sess.Token.RefreshToken != "",
+	}
+	h.render(w, "perfil.html", data)
+}
+
+// renovar fuerza una renovación con el refresh token (botón de /perfil).
+func (h *handlers) renovar(w http.ResponseWriter, r *http.Request) {
+	sess, _ := h.auth.CurrentSession(r)
+	if _, err := h.auth.Token(r.Context(), sess, true); err != nil {
+		if errors.Is(err, auth.ErrSessionEnded) {
+			http.Redirect(w, r, "/login?next=%2Fperfil", http.StatusSeeOther)
+			return
+		}
+		http.Error(w, err.Error(), http.StatusBadGateway)
+		return
+	}
+	http.Redirect(w, r, "/perfil", http.StatusSeeOther)
+}
+
+func sortedClaims(m map[string]any) []claim {
 	var claims []claim
-	for name, v := range sess.Claims {
+	for name, v := range m {
 		b, _ := json.Marshal(v)
 		claims = append(claims, claim{name, string(b)})
 	}
 	sort.Slice(claims, func(i, j int) bool { return claims[i].Name < claims[j].Name })
-	h.render(w, "perfil.html", pageData{Active: "perfil", Session: sess, Claims: claims})
+	return claims
 }
 
 func (h *handlers) render(w http.ResponseWriter, name string, data pageData) {

← Volver a la lección 04