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.
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 propiogo.mod. - En marcha
- Keycloak de cualquier paso y
go run ./cmd/webdel anexo. - Comprueba
- Entra como ana en localhost:3000: si ves su perfil, funciona.
Al terminar sabrás
- Montar un relying party con
rp.NewRelyingPartyOIDC, constatey 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.NewRelyingPartyOIDChace el descubrimiento (comooidc.NewProvider) y prepara el verificador de ID tokens.rp.WithCookieHandleryrp.WithPKCEguardan elstatey elcode_verifieren cookies cifradas del navegador. En la lección 3 los guardábamos en un mapa del servidor, más la cookie del state.rp.AuthURLHandleres el/logincompleto: genera el state, el code_challenge, la URL y redirige.rp.CodeExchangeHandleres el/callbackcompleto: comprueba el state, canjea el código con el verifier, verifica el ID token y te da los tokens ya verificados enonLogin.- El nonce no viene hecho: lo generamos en
login, lo guardamos en otra cookie cifrada y se lo pasamos al verificador conrp.WithNoncea través del contexto. Por defecto, el verificador de zitadel espera un nonce vacío: si lo envías sin configurarWithNonce, 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:
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.
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) | |
|---|---|---|
| Estilo | Piezas: descubrimiento, verificador, OAuth2. Tú escribes los handlers. | SDK: handlers de login y callback incluidos. |
| Código del login | auth.go (256 líneas) + session.go (79), muy comentados | Un main.go de 193 líneas que incluye la página y el logout |
state / PKCE | Explícitos (mapa en servidor + cookie) | Automáticos (cookies cifradas) |
| nonce | Explícito; go-oidc no lo comprueba, lo compara tu código | Hay que conectarlo con WithNonce; sin él, el verificador exige que el ID token no traiga nonce |
| Dependencias | 2 directas, muy usadas en el ecosistema | 1 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 elegirla | Quieres entender y controlar cada paso; apps pequeñas y medianas; es lo más habitual | Quieres 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.
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.