Módulo 2 · Lección 04

Sesión, refresh y logout

Tu app ya sabe quién es el usuario. Ahora gestiona el ciclo de vida completo: guarda los tokens, los renueva sin molestar, se entera cuando Keycloak cierra la sesión y, al salir, sale de verdad.

≈ 75 min Carpeta: tienda/pasos/paso-04 Parte del paso 3

En este capítulo

Trabajas en
tienda-web: renovar tokens y cerrar sesión.
Carpeta
tienda/pasos/paso-04
Archivos
Cambian internal/auth/auth.go, internal/session/session.go, internal/web/web.go, cmd/web/main.go y dos plantillas · ver todos los cambios del paso.
En marcha
Keycloak del paso 04 y go run ./cmd/web.
Comprueba
go -C tools/comprobar run . -paso 4 -servicios (desde course/)

Al terminar sabrás

  • Guardar access y refresh token en la sesión del servidor y renovarlos con TokenSource.
  • Usar el access token para llamar a una API real (el endpoint userinfo de Keycloak).
  • Detectar que Keycloak ha cerrado la sesión (invalid_grant) y reaccionar.
  • Cerrar también la sesión SSO con el logout iniciado por la app (RP-initiated logout).
  • Recibir avisos de Keycloak con el back-channel logout y verificar el logout token.

Qué cambia respecto al paso 3

ArchivoCambio
internal/session/session.goLa sesión guarda *oauth2.Token, el sid de Keycloak y un contador de renovaciones. Get devuelve copias; nuevos UpdateTokens y DeleteBySID.
internal/auth/auth.goNuevos Token (renovación), UserInfo, logout contra Keycloak y POST /backchannel-logout.
internal/web/web.go/perfil muestra el estado de los tokens y llama a userinfo; nuevo POST /perfil/renovar.
cmd/web/main.goNueva opción OIDC_POST_LOGOUT_URL (por defecto http://localhost:3000/).

El realm no cambia: http://localhost:3000/ ya estaba en Valid post logout redirect URIs desde la lección 2. La URL de back-channel la configurarás a mano en el apartado 5, porque depende de tu entorno.

1. La vida de los tokens

Con la configuración por defecto de Keycloak, esto es lo que pasa cuando ana usa la tienda durante 20 minutos y luego se va a comer:

Línea de tiempo: access tokens de 5 minutos renovados mientras ana usa la tienda; la sesión SSO caduca 30 minutos después de la última renovación 0 10 20 30 40 50 60 min ana usa la tienda inactiva access 5 min caduca ← nadie lo renueva refresh renueva SSO 30 min de inactividad desde la última renovación (20 → 50) refresh → invalid_grant «Session not active»: login
Cada renovación reinicia el contador de inactividad de la sesión SSO. Además hay un máximo absoluto de 10 horas (SSO Session Max).
Ajuste (Realm settings)Por defectoQué controla
Tokens → Access Token Lifespan5 minCuánto vale cada access token (y cada ID token).
Sessions → SSO Session Idle30 minInactividad máxima. El refresh token caduca con ella.
Sessions → SSO Session Max10 hDuración máxima de la sesión, aunque se use sin parar.
Tokens → Revoke Refresh TokenOffSi está On, cada refresh token sirve una sola vez (rotación estricta).

2. Guardar los tokens en la sesión

En el paso 3 la sesión solo guardaba la identidad. Ahora guarda el *oauth2.Token completo (access, refresh y caducidad) y el sid del ID token, que identifica la sesión SSO en Keycloak y usaremos en el back-channel logout.

Hay un cambio sutil pero importante: como varias peticiones del mismo usuario pueden leer y renovar la sesión a la vez, Get devuelve una copia, y los cambios pasan por métodos del Store que bloquean el mutex. Así no hay carreras de datos. Si tienes un compilador de C instalado, compruébalo con CGO_ENABLED=1 go run -race ./cmd/web.

Ver internal/session/session.go completo
// 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 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

import (
	"crypto/rand"
	"sync"
	"time"

	"golang.org/x/oauth2"
)

// User es la identidad del usuario, sacada del ID token ya verificado.
type User struct {
	Subject  string // claim "sub": ID estable del usuario en Keycloak
	Username string // claim "preferred_username"
	Name     string
	Email    string
}

// Session es lo que recordamos de un usuario que ha iniciado sesión.
type Session struct {
	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: 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
	sessions map[string]*Session
}

// NewStore crea un almacén cuyas sesiones caducan tras ttl.
func NewStore(ttl time.Duration) *Store {
	return &Store{ttl: ttl, sessions: make(map[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
	c := *sess
	return &c
}

// 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()
	sess, ok := s.sessions[id]
	if !ok {
		return nil, false
	}
	if time.Now().After(sess.ExpiresAt) {
		delete(s.sessions, id)
		return nil, false
	}
	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).
func (s *Store) Delete(id string) {
	s.mu.Lock()
	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
}

En el callback solo cambia la última parte: también leemos sid y pasamos el token a la sesión.

var claims struct {
	Username string `json:"preferred_username"`
	Name     string `json:"name"`
	Email    string `json:"email"`
	SID      string `json:"sid"`
}
// ...
sess := a.sessions.Create(session.User{ /* ... */ }, all, rawIDToken, claims.SID, tok)
Los tokens nunca van al navegador

El refresh token permite obtener access tokens durante horas. Si lo guardaras en una cookie legible o en localStorage, un XSS bastaría para robarlo. Aquí solo vive en la memoria del servidor; el navegador sigue teniendo únicamente el ID de sesión.

3. Renovar el access token

oauth2.Config.TokenSource devuelve una fuente de tokens que entrega el actual mientras sea válido y, si caduca (en realidad, 10 segundos antes), hace por ti un POST /token con grant_type=refresh_token. Nuestro método Token la envuelve para tres cosas más:

  1. Guardar en la sesión los tokens nuevos. Keycloak devuelve un refresh token nuevo y un ID token nuevo en cada renovación.
  2. Traducir invalid_grant a ErrSessionEnded y borrar la sesión local: Keycloak ya no reconoce la sesión.
  3. Permitir una renovación forzada (force), útil para probar y para el ejercicio 2.
// 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
}

Para que el access token sirva de algo, /perfil llama al endpoint userinfo de Keycloak con él. Es una API protegida de verdad: en el módulo 3, api-pedidos ocupará su lugar.

// 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
}
// 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)
}

Arranca el paso 4, entra y ve a Perfil. Pulsa Renovar ahora: el contador sube y el access token vuelve a durar 5 minutos.

cd tienda/pasos/paso-04
go run ./cmd/web
Página de perfil con el estado de los tokens, userinfo y el ID token
/perfil tras una renovación: estado de los tokens, respuesta de userinfo y claims del ID token.

4. Cuando Keycloak dice que no

Haz esta prueba: con ana dentro de la tienda, ve a la consola de administración, Users → ana → Sessions y pulsa Logout all sessions. Vuelve a la tienda y recarga Perfil:

Perfil: ⚠ userinfo: 401 Unauthorized
        Access token caduca en 4m31s          ← ¡el token sigue «vigente»!

Pulsas «Renovar ahora» → te manda a /login
log: refresh rechazado (Session not active): cerrando la sesión local

Esto ilustra una idea que será clave en el módulo 3:

Comprobación¿Sabe que la sesión se cerró?Por qué
Validar el JWT localmente (firma, exp)No, hasta que caduqueEl JWT es autocontenido: nadie le avisa.
Userinfo o introspecciónSí, al momentoKeycloak consulta su estado de sesiones.
RefreshSí, en la próxima renovaciónKeycloak rechaza el refresh token (invalid_grant).
Back-channel logoutSí, al momentoKeycloak avisa a la app (apartado 6).

Por eso los access tokens duran poco: su vida es el retraso máximo con el que una revocación llega a una API que solo valida localmente.

5. Logout de verdad (RP-initiated logout)

Para cerrar también la sesión SSO, la app redirige al navegador al end_session_endpoint de Keycloak, que sale del documento de descubrimiento, con dos parámetros:

  • id_token_hint: el ID token del usuario. Le dice a Keycloak qué sesión cerrar y qué client lo pide. Sin él, Keycloak pide confirmación.
  • post_logout_redirect_uri: adónde volver. Debe estar en Valid post logout redirect URIs del client.
// 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})

	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)
}

go-oidc no expone el end_session_endpoint como campo, así que New lo lee de los claims del descubrimiento:

var meta struct {
	EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err := provider.Claims(&meta); err != nil { /* ... */ }

Pruébalo: con ana dentro, pulsa Salir. Pasas un instante por Keycloak y vuelves al catálogo. Ahora Entrar sí pide la contraseña: la sesión SSO ha desaparecido.

Si quitas el id_token_hint, Keycloak no puede saber quién pide el logout y muestra esta confirmación (que además no te devuelve a la tienda):

Página de confirmación de logout de Keycloak
Logout sin id_token_hint: Keycloak pide confirmación para evitar que una web cualquiera te cierre la sesión.
¿Y si el ID token ya caducó?

No pasa nada: Keycloak verifica la firma del id_token_hint, pero no exige que esté vigente. Lo hemos comprobado con un ID token caducado: Keycloak cierra la sesión y redirige sin pedir confirmación. Aun así guardamos el último ID token de cada renovación.

6. Back-channel logout

Queda un caso: ana cierra sesión fuera de la tienda (en su consola de cuenta, en otra app del realm, o un administrador la expulsa). Su sesión en tienda-web seguiría viva hasta el próximo refresh. Con el back-channel logout, Keycloak avisa a la app al momento, de servidor a servidor:

Secuencia de back-channel logout entre navegador, Keycloak y tienda-web Navegador Keycloak tienda-web 1 «Sign out» en la consola de cuenta 2 POST /backchannel-logout logout_token (JWT firmado, con sid) 3 verifica el token y borra las sesiones con ese sid 4 200 OK 5 más tarde: GET /pedidos → sin sesión → /login
Naranja: canal trasero. El navegador no participa en el aviso (paso 2).

El logout token

Este es un logout token real emitido por Keycloak 26.8 para la sesión de ana:

// header
{ "alg": "RS256", "typ": "logout+jwt", "kid": "hZj8DLzfzhlzMB6w7asAJRwH6lLgW-aIOKUPeeuFo1s" }
// payload
{
  "exp": 1791570389,
  "iat": 1791570269,
  "jti": "ede56cc8-49a8-670c-aa74-a2c62be652cb",
  "iss": "http://localhost:8080/realms/tienda",
  "aud": "tienda-web",
  "sub": "2740bfb0-9279-42fd-ace8-0f4ecc0691b0",
  "typ": "Logout",
  "sid": "DYYtFVLB0tgOnmt42ugOjwSV",
  "events": { "http://schemas.openid.net/event/backchannel-logout": {} }
}

Lo firma la misma clave del realm y su aud es nuestro client, así que el mismo verificador de los ID tokens comprueba firma, iss, aud y exp (dura 2 minutos). Luego añadimos las comprobaciones propias de un logout token: debe traer el evento de back-channel logout y no puede traer nonce. Si no las hiciéramos, alguien podría reenviar un ID token robado a este endpoint para cerrar sesiones ajenas.

// 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)
}

Probado contra el endpoint: un ID token enviado como logout_token recibe 400 no es un logout token válido; basura, 400 logout_token inválido. Y no hace falta excluir la ruta de CrossOriginProtection: el POST de Keycloak no lleva cabeceras Origin ni Sec-Fetch-Site, así que no se trata como una petición de navegador de otro sitio.

Configurar la URL en Keycloak

En Clients → tienda-web → Settings → Logout settings, rellena Backchannel logout URL y deja Backchannel logout session required en On (para que el token traiga sid):

Logout settings de tienda-web con la URL de back-channel
Logout settings de tienda-web. Tu IP será otra.

Lo delicado es la URL: la llamada la hace Keycloak desde su contenedor, donde localhost es el propio contenedor. Depende de dónde corra tienda-web:

Entorno de tienda-webBackchannel logout URL
Windows sin WSL + Docker Desktophttp://host.docker.internal:3000/backchannel-logout. Probado, incluso con la app escuchando solo en localhost:3000 (Docker Desktop entrega la conexión como si viniera de 127.0.0.1).
macOS + Docker Desktop u OrbStackhttp://host.docker.internal:3000/backchannel-logout
Linux + Docker Enginehttp://host.docker.internal:3000/backchannel-logout. Funciona gracias al extra_hosts que ya trae el docker-compose.yml; la app debe escuchar en todas las interfaces (:3000, lo predeterminado).
WSL 2 (red NAT, la predeterminada) + Docker Desktophttp://<IP de WSL>:3000/backchannel-logout. Obtén la IP con hostname -I; cambia al reiniciar. host.docker.internal apunta a Windows, no a WSL: lo hemos probado y no llega.

Por eso esta URL no está en el JSON del realm. Para probar: entra en la tienda como ana y, desde la consola de administración, cierra sus sesiones (Users → ana → Sessions → Logout all sessions). En el log de tienda-web aparecerá al instante:

back-channel logout: sid=Mcam7zgHVH_qzwoFPreRRZ-8, 1 sesión(es) cerrada(s)
POST /backchannel-logout (0s)
GET /perfil (0s)            ← la siguiente visita ya va a /login

También funciona en el otro sentido: si en el mismo navegador abres http://localhost:8080/realms/tienda/account (entrarás sin contraseña, por el SSO) y pulsas Sign out, Keycloak cierra la sesión y avisa a tienda-web.

Ejercicios

1. Ver la renovación automática · fácil

En Realm settings → Tokens baja Access Token Lifespan a 1 minuto. Inicia sesión de nuevo, abre Perfil, espera algo más de un minuto y recarga. ¿Qué cambia? Al terminar, vuelve a dejar 5 minutos.

Ver solución

Al recargar, «Renovaciones desde el login» sube a 1 sin que pulses nada y el access token vuelve a durar 1 minuto. Token detectó que había caducado (x/oauth2 renueva 10 segundos antes de exp) y usó el refresh token. Ana no ha notado nada: para eso existe el refresh token. El cambio solo afecta a los tokens emitidos después de cambiar el ajuste; por eso hay que volver a entrar.

2. Reaccionar al 401 de userinfo · media

En el apartado 4, Perfil mostraba «userinfo: 401» y había que pulsar «Renovar ahora» para que la app se diera cuenta. Haz que /perfil lo detecte solo y mande al usuario a iniciar sesión.

Ver solución

Un 401 de userinfo no dice por qué falló. Un refresh forzado sí lo confirma: si Keycloak responde invalid_grant, Token devuelve ErrSessionEnded y borra la sesión. En perfil:

} else if ui, uerr := h.auth.UserInfo(r.Context(), tok); uerr != nil {
	// Un 401 de userinfo puede significar que la sesión ya no existe en
	// Keycloak. Un refresh forzado lo confirma y, si es así, cierra la sesión local.
	if _, rerr := h.auth.Token(r.Context(), sess, true); errors.Is(rerr, auth.ErrSessionEnded) {
		http.Redirect(w, r, auth.LoginURL(r), http.StatusFound)
		return
	}
	data.Error = "userinfo: " + uerr.Error()
} else {

Ojo con el patrón general: no conviertas cualquier error de una API en «sesión terminada». Un 500 o un timeout de la API no deberían echar al usuario.

3. Rotación estricta de refresh tokens · media

Activa Revoke Refresh Token (Realm settings → Tokens) con Refresh Token Max Reuse en 0. ¿Qué gana la seguridad? ¿Qué problema podría aparecer si el usuario abre Perfil en dos pestañas justo cuando el access token caduca?

Ver solución

Ganancia: cada refresh token sirve una sola vez, así que uno robado y usado por un atacante invalida el del usuario legítimo (y viceversa). La reutilización delata el robo.

Problema: las dos pestañas leen la misma sesión con el mismo refresh token y ambas intentan renovar a la vez. La primera gana; la segunda recibe invalid_grant y nuestro Token cerraría la sesión. La solución es serializar las renovaciones por sesión (un mutex por sesión, o golang.org/x/sync/singleflight con el ID de sesión como clave) para que solo una petición renueve y las demás esperen su resultado.

4. Sin pista · fácil

Comenta la línea de id_token_hint en handleLogout y pulsa Salir. ¿Qué ves? ¿Y si además quitas post_logout_redirect_uri?

Ver solución

Solo con post_logout_redirect_uri: Keycloak muestra una página de error, «Missing parameters: id_token_hint», porque para validar la URL de vuelta necesita saber qué client la pide (con id_token_hint o con client_id). Sin ninguno de los dos parámetros: aparece la confirmación «Do you want to log out?» y, tras confirmar, te quedas en una página de Keycloak, sin volver a la tienda.

Errores comunes

invalid_grant «Session not active» al renovar

No es un fallo: la sesión SSO terminó (inactividad, logout en otro sitio o un administrador). Qué hacer: exactamente lo que hace Token: borrar la sesión local y mandar a /login.

Invalid redirect uri Página de error de Keycloak al salir

La post_logout_redirect_uri no está en Valid post logout redirect URIs (no basta con que esté en Valid redirect URIs). Arreglo: añádela en el client o corrige OIDC_POST_LOGOUT_URL.

Missing parameters: id_token_hint Error al salir

Enviaste post_logout_redirect_uri sin id_token_hint ni client_id. Pasa cuando la sesión local ya no existía (por ejemplo, tras reiniciar la app) y el código construye la URL igualmente. Arreglo: como en handleLogout, si no tienes ID token redirige a /, o añade client_id.

Do you want to log out? Keycloak pide confirmación

Falta id_token_hint o no es válido (por ejemplo, un ID token de otro realm o de otro Keycloak tras un down -v). Arreglo: envía el último ID token de la sesión.

KC-SERVICES0057 El back-channel logout no llega

En docker compose logs keycloak verás WARN … KC-SERVICES0057: Logout for client 'tienda-web' failed: … Connect to 172.22.213.105:3999 … failed: Connection refused. Keycloak no alcanza la URL desde su contenedor. Arreglo: revisa la tabla del apartado 6 y comprueba que la IP de WSL no haya cambiado. Keycloak no reintenta: ese aviso se pierde, y la sesión local morirá en el próximo refresh.

logout_token inválido 400 en /backchannel-logout

Mira el detalle en el log de la app: token is expired (los logout tokens duran 2 minutos: ¿relojes desincronizados?), expected audience (la URL configurada es de otro client) o un issuer distinto (Keycloak se anuncia con otro host).

userinfo: 401 Con un access token aparentemente válido

La sesión SSO se cerró y el access token aún no ha caducado (apartado 4). Si no te lo esperabas, comprueba también que el token se pidió con el scope openid: userinfo es un endpoint de OIDC.

Resumen del módulo 2

tienda-web ya hace todo lo que una app web necesita con Keycloak: login con Authorization Code + PKCE, sesión en el servidor con los tokens a salvo, renovación transparente con el refresh token, detección de sesiones terminadas, logout que cierra también el SSO y avisos de Keycloak por el canal trasero. En el módulo 3 cambiamos de lado: escribiremos api-pedidos, la API que recibe esos access tokens y decide qué puede hacer cada uno.