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.
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 -denpaso-03/infra) ygo run ./cmd/web. - Comprueba
go -C tools/comprobar run . -paso 3 -servicios(desdecourse/)
Al terminar sabrás
- Configurar
go-oidcyx/oauth2a partir de la URL del issuer. - Implementar
/loginy/callbackconstate,noncey 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:
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ía | Qué hace por ti |
|---|---|
golang.org/x/oauth2 | Construye 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/v3 | Lee el documento de descubrimiento, descarga y cachea el JWKS y verifica ID tokens (firma, iss, aud, exp). |
github.com/go-jose/go-jose/v4 | Dependencia indirecta de go-oidc: la criptografía JOSE (JWS/JWK). |
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.Configcon el endpoint de autorización y el de token (provider.Endpoint()), tu client ID, el secreto, laredirect_uriy los scopes (openid profile email). - Un
*oidc.IDTokenVerifierconfigurado con tuClientID: rechazará cualquier ID token cuyoaudno seatienda-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 cookietienda_state. Así, cuando vuelva el callback, solo este navegador podrá completarlo.nonce: Keycloak lo copiará dentro del ID token.verifier: elcode_verifierde 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ódigo | Qué comprueba | Paso (lección 1) |
|---|---|---|
1. error en la URL | El usuario canceló o Keycloak rechazó la petición. | 7 |
2. state = cookie | La 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 + VerifierOption | Canje por el canal trasero con code_verifier y secreto. | 9–10 |
5. verifier.Verify + nonce | Firma, iss, aud, exp y el nonce. go-oidc no comprueba el nonce: es tarea tuya. | 11 |
6. sessions.Create | Sesió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)
}
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 cookie | Valor | Por qué |
|---|---|---|
HttpOnly | true | JavaScript no puede leerla: un XSS no roba la sesión directamente. |
SameSite | Lax | Viaja al volver de Keycloak (navegación GET), pero no en POST desde otros sitios. |
Secure | comentado | Solo 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. |
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)
- Abre http://localhost:3000 (usa
localhost, no127.0.0.1: verás por qué en los errores comunes). - Pulsa Mis pedidos. Sin sesión, la app te manda a
/login?next=%2Fpedidosy de ahí a Keycloak. - Entra como
ana/ana123. Vuelves directamente a Mis pedidos.
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:
audestienda-web, y noaccountcomo en el access token: el ID token es para tu app.typesID, y aparecen elnonceque generaste en/loginyat_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:
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:
- Arranca con
OIDC_REDIRECT_URL=http://localhost:3000/cb go run ./cmd/webe intenta entrar. - Arranca con
OIDC_CLIENT_SECRET=otro go run ./cmd/webe intenta entrar.
En PowerShell:$env:OIDC_CLIENT_SECRET="otro"; go run ./cmd/web, y despuésRemove-Item Env:OIDC_CLIENT_SECRET(lo mismo conOIDC_REDIRECT_URL). - 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
- 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.
- 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" - Respuesta
400«state inválido»: la cookietienda_statese borró en el primer callback. Aunque la recrearas, elstateya no está enpending(un solo uso) y Keycloak rechazaría el código repetido coninvalid_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.
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.
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.

