Módulo 2 · Lección 03

Login con Authorization Code + PKCE

Escribes tienda-web: una aplicación Go con catálogo público y una zona privada a la que se entra con la cuenta de Keycloak. Es el flujo de la lección 1, ahora con tu servidor como client confidencial.

≈ 75 min Carpeta: tienda/pasos/paso-03 go-oidc v3 · x/oauth2 Keycloak del paso 02 en marcha

En este capítulo

Trabajas en
tienda-web: la web de la tienda, con login.
Carpeta
tienda/pasos/paso-03
Archivos
Nuevos: cmd/web/main.go, internal/auth/auth.go, internal/session/session.go, internal/web/ (y sus plantillas) · ver todos los cambios del paso.
En marcha
Keycloak (docker compose up -d en paso-03/infra) y go run ./cmd/web.
Comprueba
go -C tools/comprobar run . -paso 3 -servicios (desde course/)

Al terminar sabrás

  • Configurar go-oidc y x/oauth2 a partir de la URL del issuer.
  • Implementar /login y /callback con state, nonce y PKCE, sin saltarte ninguna comprobación.
  • Guardar la sesión en el servidor y protegerla con cookies bien configuradas.
  • Proteger rutas con un middleware y volver a la página original tras el login.
  • Entender por qué «Salir» todavía no cierra la sesión en Keycloak.

Qué vas a construir

Al terminar, tienda-web tendrá un catálogo que cualquiera puede ver y dos páginas privadas, Mis pedidos y Perfil, que exigen iniciar sesión:

Catálogo de tienda-web sin sesión iniciada
Sin sesión: catálogo público y botón «Entrar».
Página Mis pedidos de ana
Con sesión: los pedidos de ana.

Los pedidos son de mentira (un map en memoria); en el módulo 3 vendrán de api-pedidos. Lo que importa ahora es el login.

Estructura del paso 3

paso-03/
├─ go.mod                     ← módulo "tienda" + go-oidc y x/oauth2
├─ cmd/
│  ├─ discover/               ← el programa de la lección 2
│  └─ web/main.go             ← arranque de tienda-web
├─ internal/
│  ├─ auth/auth.go            ← /login, /callback, /logout y middleware
│  ├─ session/session.go      ← sesiones en memoria
│  └─ web/                    ← páginas y plantillas HTML
│     ├─ web.go
│     └─ templates/*.html
└─ infra/                     ← igual que en el paso 2

El realm no cambia: el client tienda-web que importaste en la lección 2 ya tiene todo lo necesario. Si Keycloak sigue en marcha desde el paso 2, no tienes que tocarlo.

1. Dependencias

cd tienda/pasos/paso-03
go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2
go mod tidy
module tienda

go 1.26.0

require (
	github.com/coreos/go-oidc/v3 v3.21.0
	golang.org/x/oauth2 v0.37.0
)

require github.com/go-jose/go-jose/v4 v4.1.4 // indirect
LibreríaQué hace por ti
golang.org/x/oauth2Construye la URL de login, canjea el código por tokens (añadiendo el client_secret), genera PKCE y, en la lección 4, refresca tokens.
github.com/coreos/go-oidc/v3Lee el documento de descubrimiento, descarga y cachea el JWKS y verifica ID tokens (firma, iss, aud, exp).
github.com/go-jose/go-jose/v4Dependencia indirecta de go-oidc: la criptografía JOSE (JWS/JWK).
Necesitas Go 1.26

Las versiones actuales de estas librerías declaran go 1.26, y go mod tidy lo sube en tu go.mod. Con un Go más antiguo verás go: go.mod requires go >= 1.26; actualiza Go.

2. El paquete auth

Es el corazón de la lección. Te lo presento por partes y al final tienes el archivo completo.

Configuración a partir del issuer

auth.New llama a oidc.NewProvider, que hace exactamente lo que hacía tu programa discover: descarga /.well-known/openid-configuration y comprueba que el issuer coincida. De ahí salen dos objetos:

  • Un oauth2.Config con el endpoint de autorización y el de token (provider.Endpoint()), tu client ID, el secreto, la redirect_uri y los scopes (openid profile email).
  • Un *oidc.IDTokenVerifier configurado con tu ClientID: rechazará cualquier ID token cuyo aud no sea tienda-web.
// New lee el documento de descubrimiento del issuer y prepara el cliente OIDC.
func New(ctx context.Context, cfg Config, sessions *session.Store) (*Auth, error) {
	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
	if err != nil {
		return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", cfg.Issuer, err)
	}

	return &Auth{
		oauth: oauth2.Config{
			ClientID:     cfg.ClientID,
			ClientSecret: cfg.ClientSecret,
			RedirectURL:  cfg.RedirectURL,
			Endpoint:     provider.Endpoint(), // URLs de /auth y /token, sacadas del descubrimiento
			Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
		},
		// 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),
	}, nil
}

/login: preparar y redirigir

Por cada intento de login la app genera tres valores aleatorios y los recuerda en el servidor, en un mapa indexado por state:

  • state: también va en una cookie tienda_state. Así, cuando vuelva el callback, solo este navegador podrá completarlo.
  • nonce: Keycloak lo copiará dentro del ID token.
  • verifier: el code_verifier de PKCE. En la URL solo viaja su hash (S256ChallengeOption).
// handleLogin inicia el flujo: genera state, nonce y code_verifier y
// redirige el navegador a Keycloak.
func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
	state := rand.Text()
	nonce := rand.Text()
	verifier := oauth2.GenerateVerifier()

	a.mu.Lock()
	a.dropExpiredLocked()
	a.pending[state] = pendingLogin{
		nonce:     nonce,
		verifier:  verifier,
		returnTo:  safeReturnTo(r.URL.Query().Get("next")),
		expiresAt: time.Now().Add(pendingTTL),
	}
	a.mu.Unlock()

	// Guardamos el state también en una cookie: así solo ESTE navegador
	// puede completar este login (protección contra login CSRF).
	http.SetCookie(w, &http.Cookie{
		Name:     stateCookie,
		Value:    state,
		Path:     "/callback",
		MaxAge:   int(pendingTTL.Seconds()),
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})

	authURL := a.oauth.AuthCodeURL(state,
		oidc.Nonce(nonce),
		oauth2.S256ChallengeOption(verifier),
	)
	http.Redirect(w, r, authURL, http.StatusFound)
}

rand.Text() (Go 1.24+) devuelve 128 bits aleatorios en base32: perfecto para identificadores imposibles de adivinar.

/callback: comprobar todo y crear la sesión

Aquí es donde se gana o se pierde la seguridad. Cada paso corresponde a uno del diagrama de la lección 1:

En el códigoQué compruebaPaso (lección 1)
1. error en la URLEl usuario canceló o Keycloak rechazó la petición.7
2. state = cookieLa respuesta pertenece a un login iniciado en este navegador.8
3. pending[state]El login existe, no ha caducado y se usa una sola vez.8
4. Exchange + VerifierOptionCanje por el canal trasero con code_verifier y secreto.9–10
5. verifier.Verify + nonceFirma, iss, aud, exp y el nonce. go-oidc no comprueba el nonce: es tarea tuya.11
6. sessions.CreateSesión nueva con ID nuevo (evita la fijación de sesión).11
// handleCallback recibe ?code=…&state=… de Keycloak, canjea el código por
// tokens, verifica el ID token y crea la sesión.
func (a *Auth) handleCallback(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	// 1. ¿Keycloak devolvió un error? (p. ej. el usuario canceló)
	if e := q.Get("error"); e != "" {
		http.Error(w, "Keycloak devolvió un error: "+e+" — "+q.Get("error_description"), http.StatusBadRequest)
		return
	}

	// 2. El state de la URL debe coincidir con el de la cookie de este navegador.
	state := q.Get("state")
	c, err := r.Cookie(stateCookie)
	if err != nil || state == "" || c.Value != state {
		http.Error(w, "state inválido: vuelve a iniciar sesión", http.StatusBadRequest)
		return
	}
	http.SetCookie(w, &http.Cookie{Name: stateCookie, Path: "/callback", MaxAge: -1})

	// 3. Recuperamos nonce y code_verifier (cada state sirve una sola vez).
	a.mu.Lock()
	p, ok := a.pending[state]
	delete(a.pending, state)
	a.mu.Unlock()
	if !ok || time.Now().After(p.expiresAt) {
		http.Error(w, "login caducado o desconocido: vuelve a iniciar sesión", http.StatusBadRequest)
		return
	}

	// 4. Canal trasero: canjeamos el código por tokens enviando el code_verifier
	//    (y el client_secret, que x/oauth2 añade a partir de la Config).
	tok, err := a.oauth.Exchange(r.Context(), q.Get("code"), oauth2.VerifierOption(p.verifier))
	if err != nil {
		log.Printf("canje del código: %v", err)
		http.Error(w, "no se pudo completar el login", http.StatusBadGateway)
		return
	}

	// 5. Verificamos el ID token: firma, iss, aud, exp… y el nonce, que es cosa nuestra.
	rawIDToken, ok := tok.Extra("id_token").(string)
	if !ok {
		http.Error(w, "la respuesta de Keycloak no trae id_token", http.StatusBadGateway)
		return
	}
	idToken, err := a.verifier.Verify(r.Context(), rawIDToken)
	if err != nil {
		log.Printf("verificación del ID token: %v", err)
		http.Error(w, "ID token inválido", http.StatusUnauthorized)
		return
	}
	if idToken.Nonce != p.nonce {
		http.Error(w, "nonce inválido", http.StatusUnauthorized)
		return
	}

	// 6. Leemos los claims que nos interesan y creamos la sesión.
	var claims struct {
		Username string `json:"preferred_username"`
		Name     string `json:"name"`
		Email    string `json:"email"`
	}
	var all map[string]any
	if err := idToken.Claims(&claims); err != nil {
		http.Error(w, "claims ilegibles", http.StatusInternalServerError)
		return
	}
	if err := idToken.Claims(&all); err != nil {
		http.Error(w, "claims ilegibles", http.StatusInternalServerError)
		return
	}

	sess := a.sessions.Create(session.User{
		Subject:  idToken.Subject,
		Username: claims.Username,
		Name:     claims.Name,
		Email:    claims.Email,
	}, all, rawIDToken)

	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    sess.ID,
		Path:     "/",
		HttpOnly: true,                 // JavaScript no puede leerla
		SameSite: http.SameSiteLaxMode, // no viaja en POST de otros sitios
		// Secure: true,                // obligatorio en producción (HTTPS)
	})
	http.Redirect(w, r, p.returnTo, http.StatusFound)
}
¿Y el access token?

En esta lección no lo guardamos: la app solo necesita saber quién es el usuario, y eso lo dice el ID token. En la lección 4 guardaremos access y refresh token en la sesión para llamar a otras APIs.

Proteger rutas

RequireLogin envuelve cualquier handler: si no hay sesión, redirige a /login?next=<ruta> y, tras el login, el callback devuelve al usuario a donde iba. safeReturnTo impide usar next para mandar al usuario a otra web (open redirect):

// RequireLogin protege un handler: sin sesión, manda a /login y vuelve después.
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)
			return
		}
		next.ServeHTTP(w, r)
	})
}
// safeReturnTo solo acepta rutas locales («/pedidos»), nunca URLs de otro
// sitio («https://malo.example», «//malo.example»): evita redirecciones abiertas.
func safeReturnTo(next string) string {
	if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
		return "/"
	}
	return next
}
Ver internal/auth/auth.go completo
// Package auth implementa el login de tienda-web contra Keycloak con
// OpenID Connect: Authorization Code + PKCE, usando go-oidc y x/oauth2.
package auth

import (
	"context"
	"crypto/rand"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"

	"tienda/internal/session"
)

const (
	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
)

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

// pendingLogin es lo que recordamos entre /login y /callback.
type pendingLogin struct {
	nonce     string
	verifier  string // code_verifier de PKCE: nunca sale del servidor
	returnTo  string // adónde volver tras el login
	expiresAt time.Time
}

// Auth agrupa la configuración OIDC y los handlers de login.
type Auth struct {
	oauth    oauth2.Config
	verifier *oidc.IDTokenVerifier
	sessions *session.Store

	mu      sync.Mutex
	pending map[string]pendingLogin // clave: state
}

// New lee el documento de descubrimiento del issuer y prepara el cliente OIDC.
func New(ctx context.Context, cfg Config, sessions *session.Store) (*Auth, error) {
	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
	if err != nil {
		return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", cfg.Issuer, err)
	}

	return &Auth{
		oauth: oauth2.Config{
			ClientID:     cfg.ClientID,
			ClientSecret: cfg.ClientSecret,
			RedirectURL:  cfg.RedirectURL,
			Endpoint:     provider.Endpoint(), // URLs de /auth y /token, sacadas del descubrimiento
			Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
		},
		// 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),
	}, nil
}

// Register añade las rutas de autenticación al mux.
func (a *Auth) Register(mux *http.ServeMux) {
	mux.HandleFunc("GET /login", a.handleLogin)
	mux.HandleFunc("GET /callback", a.handleCallback)
	mux.HandleFunc("POST /logout", a.handleLogout)
}

// handleLogin inicia el flujo: genera state, nonce y code_verifier y
// redirige el navegador a Keycloak.
func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
	state := rand.Text()
	nonce := rand.Text()
	verifier := oauth2.GenerateVerifier()

	a.mu.Lock()
	a.dropExpiredLocked()
	a.pending[state] = pendingLogin{
		nonce:     nonce,
		verifier:  verifier,
		returnTo:  safeReturnTo(r.URL.Query().Get("next")),
		expiresAt: time.Now().Add(pendingTTL),
	}
	a.mu.Unlock()

	// Guardamos el state también en una cookie: así solo ESTE navegador
	// puede completar este login (protección contra login CSRF).
	http.SetCookie(w, &http.Cookie{
		Name:     stateCookie,
		Value:    state,
		Path:     "/callback",
		MaxAge:   int(pendingTTL.Seconds()),
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})

	authURL := a.oauth.AuthCodeURL(state,
		oidc.Nonce(nonce),
		oauth2.S256ChallengeOption(verifier),
	)
	http.Redirect(w, r, authURL, http.StatusFound)
}

// handleCallback recibe ?code=…&state=… de Keycloak, canjea el código por
// tokens, verifica el ID token y crea la sesión.
func (a *Auth) handleCallback(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	// 1. ¿Keycloak devolvió un error? (p. ej. el usuario canceló)
	if e := q.Get("error"); e != "" {
		http.Error(w, "Keycloak devolvió un error: "+e+" — "+q.Get("error_description"), http.StatusBadRequest)
		return
	}

	// 2. El state de la URL debe coincidir con el de la cookie de este navegador.
	state := q.Get("state")
	c, err := r.Cookie(stateCookie)
	if err != nil || state == "" || c.Value != state {
		http.Error(w, "state inválido: vuelve a iniciar sesión", http.StatusBadRequest)
		return
	}
	http.SetCookie(w, &http.Cookie{Name: stateCookie, Path: "/callback", MaxAge: -1})

	// 3. Recuperamos nonce y code_verifier (cada state sirve una sola vez).
	a.mu.Lock()
	p, ok := a.pending[state]
	delete(a.pending, state)
	a.mu.Unlock()
	if !ok || time.Now().After(p.expiresAt) {
		http.Error(w, "login caducado o desconocido: vuelve a iniciar sesión", http.StatusBadRequest)
		return
	}

	// 4. Canal trasero: canjeamos el código por tokens enviando el code_verifier
	//    (y el client_secret, que x/oauth2 añade a partir de la Config).
	tok, err := a.oauth.Exchange(r.Context(), q.Get("code"), oauth2.VerifierOption(p.verifier))
	if err != nil {
		log.Printf("canje del código: %v", err)
		http.Error(w, "no se pudo completar el login", http.StatusBadGateway)
		return
	}

	// 5. Verificamos el ID token: firma, iss, aud, exp… y el nonce, que es cosa nuestra.
	rawIDToken, ok := tok.Extra("id_token").(string)
	if !ok {
		http.Error(w, "la respuesta de Keycloak no trae id_token", http.StatusBadGateway)
		return
	}
	idToken, err := a.verifier.Verify(r.Context(), rawIDToken)
	if err != nil {
		log.Printf("verificación del ID token: %v", err)
		http.Error(w, "ID token inválido", http.StatusUnauthorized)
		return
	}
	if idToken.Nonce != p.nonce {
		http.Error(w, "nonce inválido", http.StatusUnauthorized)
		return
	}

	// 6. Leemos los claims que nos interesan y creamos la sesión.
	var claims struct {
		Username string `json:"preferred_username"`
		Name     string `json:"name"`
		Email    string `json:"email"`
	}
	var all map[string]any
	if err := idToken.Claims(&claims); err != nil {
		http.Error(w, "claims ilegibles", http.StatusInternalServerError)
		return
	}
	if err := idToken.Claims(&all); err != nil {
		http.Error(w, "claims ilegibles", http.StatusInternalServerError)
		return
	}

	sess := a.sessions.Create(session.User{
		Subject:  idToken.Subject,
		Username: claims.Username,
		Name:     claims.Name,
		Email:    claims.Email,
	}, all, rawIDToken)

	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    sess.ID,
		Path:     "/",
		HttpOnly: true,                 // JavaScript no puede leerla
		SameSite: http.SameSiteLaxMode, // no viaja en POST de otros sitios
		// Secure: true,                // obligatorio en producción (HTTPS)
	})
	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).
func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie(sessionCookie); err == nil {
		a.sessions.Delete(c.Value)
	}
	http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1})
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

// CurrentSession devuelve la sesión del usuario de esta petición, si la hay.
func (a *Auth) CurrentSession(r *http.Request) (*session.Session, bool) {
	c, err := r.Cookie(sessionCookie)
	if err != nil {
		return nil, false
	}
	return a.sessions.Get(c.Value)
}

// RequireLogin protege un handler: sin sesión, manda a /login y vuelve después.
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)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// dropExpiredLocked borra logins a medio hacer que ya caducaron.
// Debe llamarse con a.mu bloqueado.
func (a *Auth) dropExpiredLocked() {
	now := time.Now()
	for k, p := range a.pending {
		if now.After(p.expiresAt) {
			delete(a.pending, k)
		}
	}
}

// safeReturnTo solo acepta rutas locales («/pedidos»), nunca URLs de otro
// sitio («https://malo.example», «//malo.example»): evita redirecciones abiertas.
func safeReturnTo(next string) string {
	if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
		return "/"
	}
	return next
}

3. Sesiones en el servidor

El navegador solo guarda una cookie con un ID aleatorio; los datos viven en un mapa protegido por un mutex. Así los tokens nunca llegan al navegador, donde un script podría robarlos.

// 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.
// 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"
)

// 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; lo usaremos para el logout (lección 4)
	ExpiresAt time.Time
}

// Store es un almacén de sesiones en memoria, seguro para uso concurrente.
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 la devuelve.
func (s *Store) Create(u User, claims map[string]any, rawIDToken string) *Session {
	sess := &Session{
		ID:        rand.Text(), // 128 bits aleatorios (Go 1.24+)
		User:      u,
		Claims:    claims,
		IDToken:   rawIDToken,
		ExpiresAt: time.Now().Add(s.ttl),
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.sessions[sess.ID] = sess
	return sess
}

// Get devuelve 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
	}
	return sess, true
}

// Delete elimina la sesión (logout).
func (s *Store) Delete(id string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.sessions, id)
}
Atributo de la cookieValorPor qué
HttpOnlytrueJavaScript no puede leerla: un XSS no roba la sesión directamente.
SameSiteLaxViaja al volver de Keycloak (navegación GET), pero no en POST desde otros sitios.
SecurecomentadoSolo HTTPS. Imprescindible en producción; en http://localhost lo dejamos fuera.
Path/ (sesión), /callback (state)La cookie del state solo se envía donde hace falta.
Sesiones en memoria

Si reinicias tienda-web, todos los usuarios pierden la sesión, y con dos réplicas detrás de un balanceador no funcionaría. En producción guarda las sesiones en Redis o en una base de datos; la interfaz Create/Get/Delete no cambia.

4. Las páginas

El paquete web registra el catálogo (público) y envuelve /pedidos y /perfil con RequireLogin. Los patrones GET /{$} y GET /pedidos son la sintaxis de http.ServeMux desde Go 1.22: método y ruta exacta, sin routers externos.

// Register añade las páginas al mux. /pedidos y /perfil exigen sesión.
func Register(mux *http.ServeMux, a *auth.Auth) {
	h := &handlers{auth: a, pages: make(map[string]*template.Template)}
	for _, name := range []string{"home.html", "pedidos.html", "perfil.html"} {
		h.pages[name] = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/"+name))
	}

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

La plantilla común decide qué mostrar en la cabecera según haya sesión o no. Fíjate en que «Salir» es un formulario POST, no un enlace: así ninguna imagen ni enlace de otra web puede cerrarte la sesión.

{{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>
<table>
  <thead><tr><th>Claim</th><th>Valor</th></tr></thead>
  <tbody>
  {{range .Claims}}
    <tr><td><code>{{.Name}}</code></td><td><code>{{.Value}}</code></td></tr>
  {{end}}
  </tbody>
</table>
{{end}}

5. El programa principal

// Command web es tienda-web: el catálogo público y la zona de cliente,
// con login a través de Keycloak (OpenID Connect).
//
// Uso (desde tienda/pasos/paso-03):
//
//	go run ./cmd/web
package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"tienda/internal/auth"
	"tienda/internal/session"
	"tienda/internal/web"
)

func main() {
	cfg := auth.Config{
		Issuer:       env("OIDC_ISSUER", "http://localhost:8080/realms/tienda"),
		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"),
	}
	addr := env("ADDR", ":3000")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	sessions := session.NewStore(8 * time.Hour)
	a, err := auth.New(ctx, cfg, sessions)
	if err != nil {
		log.Fatal(err) // ¿Keycloak está arrancado?
	}

	mux := http.NewServeMux()
	a.Register(mux)
	web.Register(mux, a)

	srv := &http.Server{
		Addr: addr,
		// CrossOriginProtection (Go 1.25+) rechaza POST de otros orígenes: CSRF en /logout.
		Handler:           logRequests(http.NewCrossOriginProtection().Handler(mux)),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Printf("tienda-web escuchando en http://%s (issuer %s)", listenHost(addr), cfg.Issuer)
	log.Fatal(srv.ListenAndServe())
}

// logRequests escribe una línea por petición: útil para seguir el flujo.
func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
	})
}

func env(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

// listenHost convierte ":3000" en "localhost:3000" para mostrar la URL.
func listenHost(addr string) string {
	if strings.HasPrefix(addr, ":") {
		return "localhost" + addr
	}
	return addr
}

Dos detalles: la configuración sale de variables de entorno con valores por defecto para desarrollo, y http.NewCrossOriginProtection (Go 1.25+) rechaza con 403 cualquier POST que venga de otro origen, usando las cabeceras Sec-Fetch-Site y Origin que envían los navegadores. Es la protección CSRF de /logout sin tokens anti-CSRF.

6. Ejecutarlo

cd tienda/pasos/paso-03
go run ./cmd/web
# 2026/10/09 15:05:20 tienda-web escuchando en http://localhost:3000 (issuer http://localhost:8080/realms/tienda)
  1. Abre http://localhost:3000 (usa localhost, no 127.0.0.1: verás por qué en los errores comunes).
  2. Pulsa Mis pedidos. Sin sesión, la app te manda a /login?next=%2Fpedidos y de ahí a Keycloak.
  3. Entra como ana / ana123. Vuelves directamente a Mis pedidos.
Formulario de login del realm tienda
El formulario es de Keycloak (fíjate en la URL: localhost:8080). tienda-web nunca ve la contraseña.

En la terminal verás el recorrido completo, que coincide con el diagrama de la lección 1:

GET /pedidos (0s)          ← sin sesión: 302 a /login?next=/pedidos
GET /login (0s)            ← 302 a Keycloak con state, nonce y code_challenge
GET /callback (24ms)       ← canje del código + verificación del ID token
GET /pedidos (0s)          ← ya con sesión

Ahora abre Perfil. Son los claims del ID token; compáralos con el access token de la lección 1:

Página de perfil con los claims del ID token de ana
Claims del ID token de ana.
  • aud es tienda-web, y no account como en el access token: el ID token es para tu app.
  • typ es ID, y aparecen el nonce que generaste en /login y at_hash, un hash del access token que lo vincula a este ID token.
  • No hay roles: por defecto Keycloak solo los pone en el access token (ejercicio 2).

7. «Salir»… pero no del todo

Pulsa Salir y después Entrar. ¡No te pide la contraseña! Vuelves a estar dentro al instante.

Es el single sign-on en acción. Hay dos sesiones: la de tienda-web (la cookie tienda_session) y la de Keycloak (sus propias cookies en localhost:8080). «Salir» solo borra la primera; al volver a /auth, Keycloak ve que ya te conoce y emite un código nuevo sin preguntar. Lo puedes comprobar en la consola, en Users → ana → Sessions:

Sesiones de ana en la consola de Keycloak
Sesiones SSO de ana en Keycloak: una por navegador, con los clients en los que ha entrado.

Que ese sea el comportamiento deseado depende de la aplicación. En una tienda, «Salir» debería cerrar también la sesión de Keycloak: lo harás en la lección 4 con el logout iniciado por el cliente.

Ejercicios

1. Rómpelo a propósito · fácil

Provoca cada error, observa qué pasa y dónde aparece el mensaje, y deja todo como estaba:

  1. Arranca con OIDC_REDIRECT_URL=http://localhost:3000/cb go run ./cmd/web e intenta entrar.
  2. Arranca con OIDC_CLIENT_SECRET=otro go run ./cmd/web e intenta entrar.
    En PowerShell: $env:OIDC_CLIENT_SECRET="otro"; go run ./cmd/web, y después Remove-Item Env:OIDC_CLIENT_SECRET (lo mismo con OIDC_REDIRECT_URL).
  3. Inicia sesión, copia la URL completa del callback desde las DevTools (pestaña Network, petición /callback) y vuelve a abrirla.
Ver solución
  1. Keycloak muestra una página de error «Invalid parameter: redirect_uri» y nunca vuelve a tu app: la URI no está en Valid redirect URIs. Es la defensa contra que alguien te robe el código.
  2. El login en Keycloak funciona, pero el canje falla. La app responde «no se pudo completar el login» y en su log aparece:
    canje del código: oauth2: "unauthorized_client" "Invalid client or Invalid client credentials"
  3. Respuesta 400 «state inválido»: la cookie tienda_state se borró en el primer callback. Aunque la recrearas, el state ya no está en pending (un solo uso) y Keycloak rechazaría el código repetido con invalid_grant / Code not valid.

2. Enlace «Admin» solo para carlos · media

Muestra en la cabecera un enlace Admin solo a los usuarios con el rol de realm admin. Como el ID token no trae roles, primero tendrás que pedirle a Keycloak que los incluya.

Ver solución

1. Keycloak. En Clients → tienda-web → Client scopes → tienda-web-dedicated → Configure a new mapper (o Add mapper → By configuration) elige User Realm Role con:

  • Name: roles de realm en el ID token
  • Token Claim Name: roles · Multivalued: On
  • Add to ID token: On · Add to access token: Off · Add to userinfo: Off

O, si prefieres el JSON del realm, añade a tienda-web:

"protocolMappers": [
  {
    "name": "roles de realm en el ID token",
    "protocol": "openid-connect",
    "protocolMapper": "oidc-usermodel-realm-role-mapper",
    "config": {
      "claim.name": "roles",
      "jsonType.label": "String",
      "multivalued": "true",
      "id.token.claim": "true",
      "access.token.claim": "false",
      "userinfo.token.claim": "false"
    }
  }
]

2. Sesión (internal/session/session.go, añade "slices" a los imports):

type User struct {
	// ... campos existentes ...
	Roles []string // claim "roles"
}

// HasRole indica si el usuario tiene el rol de realm r.
func (u User) HasRole(r string) bool { return slices.Contains(u.Roles, r) }

3. Callback (internal/auth/auth.go):

var claims struct {
	Username string   `json:"preferred_username"`
	Name     string   `json:"name"`
	Email    string   `json:"email"`
	Roles    []string `json:"roles"`
}
// ...
sess := a.sessions.Create(session.User{
	// ... campos existentes ...
	Roles: claims.Roles,
}, all, rawIDToken)

4. Plantilla (layout.html, dentro de <nav>):

{{if and .Session (.Session.User.HasRole "admin")}}<a href="/admin">Admin</a>{{end}}

Cierra sesión y vuelve a entrar (los roles se leen al crear la sesión): carlos ve el enlace y ana no. En Perfil aparecerá el claim roles.

Esto es solo interfaz

Ocultar un enlace no protege nada: la ruta /admin también tendría que comprobar el rol en el servidor. Y la autorización de verdad, la de los datos, la hará la API con el access token (lección 6).

3. Pedir la contraseña siempre · fácil

Para operaciones delicadas quieres que el usuario vuelva a escribir la contraseña aunque tenga sesión SSO. Haz que /login?fresh=1 fuerce la reautenticación en Keycloak.

Ver solución

OIDC define el parámetro prompt=login. En handleLogin:

opts := []oauth2.AuthCodeOption{oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier)}
if r.URL.Query().Get("fresh") == "1" {
	opts = append(opts, oauth2.SetAuthURLParam("prompt", "login"))
}
authURL := a.oauth.AuthCodeURL(state, opts...)

Para comprobar en el callback que de verdad hubo login reciente, mira el claim auth_time del ID token (segundos Unix del último login real).

4. ¿Qué pasa si reinicias? · fácil

Con ana dentro, para tienda-web (Ctrl+C) y vuelve a arrancarlo. Recarga Mis pedidos. ¿Qué ves y por qué? ¿Cambiaría algo si Keycloak también se reiniciara?

Ver solución

Las sesiones de la app estaban en memoria y se han perdido, así que RequireLogin te manda a /login. Pero como la sesión SSO de Keycloak sigue viva, vuelves a entrar sin escribir la contraseña: parece que no ha pasado nada, salvo un par de redirecciones. ¿Y si reinicias Keycloak? Tampoco: desde la versión 26, Keycloak guarda las sesiones de usuario en su base de datos (PostgreSQL en nuestro caso), así que la sesión SSO sobrevive al reinicio. Lo hemos comprobado con docker compose restart keycloak. Solo docker compose down -v, que borra la base de datos, te obliga a escribir la contraseña de nuevo.

Errores comunes

Invalid parameter: redirect_uri Keycloak no vuelve a la app

La RedirectURL de la app no coincide exactamente con una de Valid redirect URIs (ojo con el puerto, la barra final, http frente a https y localhost frente a 127.0.0.1). Arreglo: corrige una de las dos. No pongas * como comodín.

Client not found. Keycloak no conoce el client

El client_id está mal escrito o el client está en otro realm (¿apunta el issuer a /realms/tienda?). Arreglo: revisa OIDC_CLIENT_ID y OIDC_ISSUER.

state inválido Al volver de Keycloak, la app rechaza el login

La cookie tienda_state no llegó al callback. La causa típica: abriste la app en http://127.0.0.1:3000, la cookie se guardó para 127.0.0.1 y Keycloak te devolvió a http://localhost:3000/callback, otro host para el navegador. También pasa si tardas más de 10 minutos en el formulario. Arreglo: usa siempre el mismo host que en la RedirectURL.

unauthorized_client «Invalid client or Invalid client credentials»

El secreto no coincide con el de la pestaña Credentials del client (quizá lo regeneraste). Arreglo: copia el actual en OIDC_CLIENT_SECRET.

invalid_grant «Code not valid»

El código ya se usó, caducó (Keycloak da 1 minuto por defecto: el Client Login Timeout de Realm settings → Tokens) o no corresponde a la redirect_uri. Suele pasar al recargar la página /callback. Arreglo: vuelve a iniciar sesión desde /login.

invalid_grant «PKCE code verifier not specified»

El client exige PKCE y el canje no envía code_verifier. Arreglo: pasa oauth2.VerifierOption(verifier) a Exchange, con el mismo verifier cuyo hash enviaste en /login.

issuer URL … did not match La app no arranca

oidc: issuer URL provided to client ("…") did not match the issuer URL returned by provider ("…"): el OIDC_ISSUER no es idéntico al que anuncia Keycloak (una barra final de más, otro host o puerto). Arreglo: copia el valor exacto del campo issuer que muestra go run ./cmd/discover.

id token issued by a different provider Verificación fallida

La app y el navegador hablan con Keycloak usando hosts distintos (por ejemplo, la app dentro de Docker con http://keycloak:8080 y el navegador con http://localhost:8080), así que el iss del token no es el que espera la app. Arreglo: un único hostname para todos; en producción, --hostname en Keycloak.

expected audience oidc: expected audience "…" got […]

El ClientID del verificador no es el client que pidió el token. Arreglo: usa el mismo ClientID en oauth2.Config y en oidc.Config.

connection refused descubrimiento OIDC en … al arrancar

Keycloak no está en marcha o aún arranca. Arreglo: docker compose up -d en infra/ y espera a /health/ready.

Resumen

tienda-web delega el login en Keycloak con Authorization Code + PKCE: /login genera state, nonce y code_verifier; /callback comprueba el state con una cookie, canjea el código por el canal trasero, verifica el ID token y su nonce, y crea una sesión en el servidor. «Salir» todavía solo cierra la sesión local: Keycloak te recuerda gracias al SSO.