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.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/web/main.go | modificado | +2 −0 |
internal/auth/auth.go | modificado | +141 −18 |
internal/session/session.go | modificado | +46 −7 |
internal/web/templates/layout.html | modificado | +1 −0 |
internal/web/templates/perfil.html | modificado | +25 −1 |
internal/web/web.go | modificado | +58 −2 |
cmd/web/main.go
@@ -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
@@ -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, ¤t).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
@@ -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
@@ -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
@@ -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
@@ -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) {