Anexo A

El mismo login con zitadel/oidc

En el curso usamos go-oidc + x/oauth2, piezas pequeñas que montas tú. github.com/zitadel/oidc es un SDK más completo: trae los handlers de login y callback ya hechos. Aquí tienes el login de la lección 3 reescrito con él, para comparar.

≈ 30 min Carpeta: tienda/pasos/anexo-a (módulo aparte) zitadel/oidc v3.51 Keycloak de cualquier paso

En este capítulo

Trabajas en
tienda-web rehecha con otra librería (zitadel/oidc), para compararla con la del curso.
Carpeta
tienda/pasos/anexo-a (módulo aparte)
Archivos
Uno: cmd/web/main.go, con su propio go.mod.
En marcha
Keycloak de cualquier paso y go run ./cmd/web del anexo.
Comprueba
Entra como ana en localhost:3000: si ves su perfil, funciona.

Al terminar sabrás

  • Montar un relying party con rp.NewRelyingPartyOIDC, con state y PKCE automáticos.
  • Qué hace por ti (y qué no) un SDK más completo.
  • Guardar la sesión en una cookie cifrada en lugar de en memoria.
  • Elegir entre las dos librerías.

1. El código

Es un módulo Go independiente (module anexo), para no añadir zitadel/oidc a la tienda. Usa el mismo client tienda-web, así que funciona con el Keycloak de cualquier paso. Asegúrate de que no haya otro tienda-web ocupando el puerto 3000.

cd tienda/pasos/anexo-a
go run ./cmd/web
# tienda-web (zitadel/oidc) en http://localhost:3000
// Command web es el login de tienda-web (lección 3) reescrito con
// github.com/zitadel/oidc en lugar de go-oidc + x/oauth2.
//
// zitadel/oidc trae los handlers de /login y /callback hechos: genera y
// comprueba el state, hace PKCE y verifica el ID token. Aquí además guardamos
// la sesión en una cookie cifrada, en vez de en memoria del servidor.
//
// Uso (desde tienda/pasos/anexo-a, con Keycloak de cualquier paso ≥ 2):
//
//	go run ./cmd/web
package main

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"html/template"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/zitadel/oidc/v3/pkg/client/rp"
	httphelper "github.com/zitadel/oidc/v3/pkg/http"
	"github.com/zitadel/oidc/v3/pkg/oidc"
)

const sessionCookie = "tienda_sesion"

// session es lo que guardamos, cifrado, en la cookie del navegador.
type session struct {
	Subject  string `json:"sub"`
	Username string `json:"usr"`
	Name     string `json:"name"`
	Email    string `json:"email"`
	IDToken  string `json:"idt"` // para el id_token_hint del logout
}

type app struct {
	provider rp.RelyingParty
	cookies  *httphelper.CookieHandler
}

func main() {
	issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
	// Clave de 32 bytes para firmar y cifrar las cookies (AES-256). En
	// producción: aleatoria, secreta y compartida por todas las réplicas.
	key := []byte(env("COOKIE_KEY", "esta-clave-es-solo-para-el-curso"))

	// WithUnsecure quita el atributo Secure: estamos en http://localhost.
	cookies := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	provider, err := rp.NewRelyingPartyOIDC(ctx, issuer,
		env("OIDC_CLIENT_ID", "tienda-web"),
		env("OIDC_CLIENT_SECRET", "tienda-web-secret"),
		env("OIDC_REDIRECT_URL", "http://localhost:3000/callback"),
		[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail},
		rp.WithCookieHandler(cookies), // state en una cookie cifrada
		rp.WithPKCE(cookies),          // code_verifier también
		rp.WithVerifierOpts(rp.WithNonce(nonceFromContext)),
	)
	if err != nil {
		log.Fatal(err)
	}

	a := &app{provider: provider, cookies: cookies}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", a.home)
	mux.HandleFunc("GET /login", a.login)
	// CodeExchangeHandler comprueba el state, canjea el código (con PKCE),
	// verifica el ID token (firma, iss, aud, exp y nonce) y llama a onLogin.
	mux.Handle("GET /callback", a.withNonce(rp.CodeExchangeHandler(a.onLogin, provider)))
	mux.HandleFunc("POST /logout", a.logout)

	srv := &http.Server{
		Addr:              env("ADDR", ":3000"),
		Handler:           http.NewCrossOriginProtection().Handler(mux),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Printf("tienda-web (zitadel/oidc) en http://%s", listenHost(srv.Addr))
	log.Fatal(srv.ListenAndServe())
}

// login genera un nonce, lo guarda en una cookie cifrada y delega en
// AuthURLHandler, que crea el state y el code_challenge y redirige a Keycloak.
func (a *app) login(w http.ResponseWriter, r *http.Request) {
	nonce := rand.Text()
	if err := a.cookies.SetCookie(w, "nonce", nonce); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	stateFn := func() string { return rand.Text() }
	rp.AuthURLHandler(stateFn, a.provider, rp.WithURLParam("nonce", nonce)).ServeHTTP(w, r)
}

type nonceKey struct{}

// withNonce pone en el contexto el nonce de la cookie, para que el
// verificador de zitadel lo compare con el del ID token (nonceFromContext).
func (a *app) withNonce(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		nonce, err := a.cookies.CheckCookie(r, "nonce")
		if err != nil {
			http.Error(w, "falta el nonce: vuelve a iniciar sesión", http.StatusBadRequest)
			return
		}
		a.cookies.DeleteCookie(w, "nonce")
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), nonceKey{}, nonce)))
	})
}

func nonceFromContext(ctx context.Context) string {
	n, _ := ctx.Value(nonceKey{}).(string)
	return n
}

// onLogin se ejecuta con los tokens ya verificados: guardamos la sesión.
func (a *app) onLogin(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, _ rp.RelyingParty) {
	c := tokens.IDTokenClaims
	raw, _ := json.Marshal(session{
		Subject:  c.Subject,
		Username: c.PreferredUsername,
		Name:     c.Name,
		Email:    c.Email,
		IDToken:  tokens.IDToken,
	})
	if err := a.cookies.SetCookie(w, sessionCookie, string(raw)); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/", http.StatusFound)
}

// current lee y descifra la sesión de la cookie, si la hay.
func (a *app) current(r *http.Request) (*session, bool) {
	raw, err := a.cookies.CheckCookie(r, sessionCookie)
	if err != nil {
		return nil, false
	}
	var s session
	if json.Unmarshal([]byte(raw), &s) != nil {
		return nil, false
	}
	return &s, true
}

// logout borra la cookie y cierra la sesión en Keycloak (RP-initiated logout).
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
	s, ok := a.current(r)
	a.cookies.DeleteCookie(w, sessionCookie)
	if !ok {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	u, err := rp.EndSession(r.Context(), a.provider, s.IDToken, "http://localhost:3000/", "", "", nil)
	if err != nil {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, u.String(), http.StatusSeeOther)
}

var page = template.Must(template.New("home").Parse(`<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>Tienda Go (zitadel/oidc)</title>
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:640px;margin:3rem auto;padding:0 1rem}
.btn{background:#00758f;color:#fff;border:0;border-radius:8px;padding:.5rem 1rem;font:inherit;text-decoration:none;cursor:pointer}
code{background:#f0f2f5;padding:.1rem .3rem;border-radius:4px}</style></head><body>
<h1>Tienda Go <small>(zitadel/oidc)</small></h1>
{{if .}}
<p>Hola, <strong>{{.Name}}</strong> ({{.Username}}, {{.Email}})</p>
<p><code>sub</code>: <code>{{.Subject}}</code></p>
<form method="post" action="/logout"><button class="btn">Salir</button></form>
{{else}}
<p><a class="btn" href="/login">Entrar</a></p>
{{end}}
</body></html>`))

func (a *app) home(w http.ResponseWriter, r *http.Request) {
	s, _ := a.current(r)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := page.Execute(w, s); err != nil {
		log.Print(err)
	}
}

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
}

2. Qué hace cada pieza

  • rp.NewRelyingPartyOIDC hace el descubrimiento (como oidc.NewProvider) y prepara el verificador de ID tokens.
  • rp.WithCookieHandler y rp.WithPKCE guardan el state y el code_verifier en cookies cifradas del navegador. En la lección 3 los guardábamos en un mapa del servidor, más la cookie del state.
  • rp.AuthURLHandler es el /login completo: genera el state, el code_challenge, la URL y redirige.
  • rp.CodeExchangeHandler es el /callback completo: comprueba el state, canjea el código con el verifier, verifica el ID token y te da los tokens ya verificados en onLogin.
  • El nonce no viene hecho: lo generamos en login, lo guardamos en otra cookie cifrada y se lo pasamos al verificador con rp.WithNonce a través del contexto. Por defecto, el verificador de zitadel espera un nonce vacío: si lo envías sin configurar WithNonce, el login falla (ejercicio 1).

3. Lo que pasa al probarlo

Probado contra el Keycloak del paso 10, con curl siguiendo el flujo:

GET /login → 302 http://localhost:8080/realms/tienda/protocol/openid-connect/auth?client_id=tienda-web
             &code_challenge=…&code_challenge_method=S256&nonce=…&redirect_uri=…&response_type=code
             &scope=openid+profile+email&state=…
tras el login: «Hola, Ana Cliente (ana, ana@tienda.test) sub: 00000000-0000-4000-8000-0000000000a1»
cookie tienda_sesion: 2480 bytes (cifrada)
GET /callback sin cookies → 400 «falta el nonce: vuelve a iniciar sesión»

Dos diferencias que no se ven en el código:

La sesión vive en el navegador

No hay mapa de sesiones: los datos del usuario y el ID token van cifrados en la cookie (2,4 KB). Ventajas: sobrevive a reinicios y funciona con varias réplicas sin Redis. Inconvenientes: no puedes cerrar la sesión de alguien desde el servidor (no hay nada que borrar; el back-channel logout de la lección 4 no tendría dónde actuar), las cookies tienen un límite de unos 4 KB, y si guardaras ahí el refresh token, quien robe la clave de cifrado los tendría todos.

El logout va por el servidor

rp.EndSession no redirige al navegador a Keycloak como hacíamos en la lección 4: llama él mismo al end_session_endpoint con el id_token_hint y devuelve adónde ir. Lo comprobamos: tras el logout, ana tenía 0 sesiones en Keycloak y el siguiente login pedía la contraseña. Funciona porque, con id_token_hint, Keycloak sabe qué sesión cerrar sin necesitar las cookies del navegador.

4. Comparación

go-oidc + x/oauth2 (lección 3)zitadel/oidc (este anexo)
EstiloPiezas: descubrimiento, verificador, OAuth2. Tú escribes los handlers.SDK: handlers de login y callback incluidos.
Código del loginauth.go (256 líneas) + session.go (79), muy comentadosUn main.go de 193 líneas que incluye la página y el logout
state / PKCEExplícitos (mapa en servidor + cookie)Automáticos (cookies cifradas)
nonceExplícito; go-oidc no lo comprueba, lo compara tu códigoHay que conectarlo con WithNonce; sin él, el verificador exige que el ID token no traiga nonce
Dependencias2 directas, muy usadas en el ecosistema1 directa, que arrastra más (go-jose, securecookie, schema…)
Además trae—Servidor OIDC (OP), cliente para resource servers (introspección), token exchange, device flow
Cuándo elegirlaQuieres entender y controlar cada paso; apps pequeñas y medianas; es lo más habitualQuieres menos código propio, o necesitas lo que trae además (por ejemplo, token exchange sin escribirlo a mano)

Cualquiera de las dos es una buena elección: hablan el mismo protocolo con Keycloak. Lo importante es lo que viste en el curso. Sea cual sea la librería, comprueba que state, PKCE, nonce, firma, iss, aud y exp se están comprobando de verdad.

Ejercicios

1. Quita el nonce · fácil

Elimina rp.WithVerifierOpts(rp.WithNonce(…)) y el middleware withNonce, pero deja que login siga enviando el parámetro. ¿Sigue funcionando el login? ¿Qué protección has perdido?

Ver solución

No funciona. Keycloak copia el nonce en el ID token y el verificador de zitadel, sin WithNonce, espera uno vacío. Respuesta real del callback:

401 failed to exchange token: nonce does not match: expected "" but was "YQV22PXY6E5Q5OARDHP72BEMGK"

Si además dejas de enviar el nonce, el login vuelve a funcionar, pero sin la protección contra la reutilización de un ID token robado (lección 1). Moraleja: lee qué comprueba de verdad un SDK, en vez de suponerlo. Nosotros también lo supusimos mal antes de probarlo.

2. Sesión en el servidor · media

Cambia la cookie cifrada por el session.Store de la tienda (copia el paquete): en la cookie, solo el ID de sesión. ¿Qué ganas y qué pierdes?

Ver solución

En onLogin, crea la sesión en el store y guarda en la cookie solo su ID (con http.SetCookie, como en la lección 3). Ganas poder revocar sesiones desde el servidor (logout de un usuario concreto, back-channel logout) y cookies pequeñas. Pierdes la ausencia de estado: hace falta un almacén compartido si hay varias réplicas.

Fin del curso

Has recorrido OAuth2 y OIDC desde cero, Keycloak en Docker, login con PKCE, sesiones y logout, una API protegida con roles y scopes, servicios con Client Credentials y Token Exchange, y la automatización de Keycloak desde Go. La tienda completa está en tienda/pasos/paso-10.