Módulo 1 · Lección 01

OAuth2 y OIDC desde cero

Antes de escribir una línea de Go necesitas el vocabulario: quién participa, qué tokens existen, qué contiene un JWT y por qué el flujo de login tiene tantos pasos.

≈ 45 min Teoría + 4 ejercicios Sin Keycloak todavía

En este capítulo

Trabajas en
Nada todavía: los conceptos (OAuth2, OIDC, tokens, PKCE) que usa todo el curso.
Carpeta
Ninguna.
En marcha
Nada. Solo leer y pensar.
Comprueba
Los ejercicios del final: si los resuelves, estás listo para la lección 2.

Al terminar sabrás

  • Diferenciar autenticación (quién eres) de autorización (qué puedes hacer).
  • Nombrar los cuatro roles de OAuth2 y qué añade OpenID Connect.
  • Distinguir access token, ID token y refresh token, y leer los claims de un JWT de Keycloak.
  • Elegir el flujo adecuado para cada parte de la tienda.
  • Explicar paso a paso Authorization Code + PKCE y para qué sirven state, nonce y code_verifier.

El problema que resuelve Keycloak

Imagina que cada programa de la tienda gestionara sus propios usuarios: tienda-web con su tabla de contraseñas, api-pedidos con otra, y el panel de administración con una tercera. Tendrías contraseñas repetidas, tres sitios que asegurar, tres formularios de login y ninguna forma sencilla de dar o quitar permisos.

La alternativa es delegar la identidad en un único servicio especializado, un proveedor de identidad (IdP). Keycloak es ese IdP: guarda usuarios y roles, muestra el formulario de login y entrega a las aplicaciones tokens firmados que dicen quién es el usuario y qué puede hacer. Las aplicaciones nunca ven la contraseña.

Autenticación ≠ autorización

Autenticación: comprobar quién eres (ana, con su contraseña). Autorización: decidir qué puedes hacer (ana puede ver sus pedidos, no los de todos). OAuth2 nació para la autorización; OpenID Connect le añadió la autenticación.

OAuth2 en una página

OAuth2 (RFC 6749) define cómo una aplicación obtiene un access token para llamar a una API en nombre de alguien. Participan cuatro roles; así se llaman en nuestra tienda:

Rol OAuth2Qué esEn la tienda
Resource ownerEl dueño de los datos; normalmente una persona.ana, carlos
ClientLa aplicación que quiere acceder a los datos.tienda-web, facturacion
Authorization serverAutentica y emite tokens.Keycloak (realm tienda)
Resource serverLa API que protege los datos y acepta tokens.api-pedidos

Un mismo programa puede tener dos papeles: facturacion es client cuando pide pedidos a la API, y podría ser resource server de su propia API.

Qué añade OpenID Connect

OAuth2 por sí solo no dice quién es el usuario; solo entrega un permiso. OpenID Connect (OIDC) es una capa encima que estandariza la identidad:

  • ID token: un JWT para la aplicación con la identidad del usuario (sub, nombre, email…).
  • Scope openid: pedirlo convierte una petición OAuth2 en un login OIDC. Otros scopes estándar: profile, email.
  • Endpoint userinfo: devuelve los datos del usuario a cambio de un access token.
  • Descubrimiento: un documento JSON en /.well-known/openid-configuration con todas las URLs y capacidades del servidor. Las librerías de Go lo leen solas a partir de la URL del issuer.

Los tres tokens

TokenPara quiénPara quéVida por defecto en Keycloak
Access tokenLa API (resource server)Se envía en Authorization: Bearer …. La API lo valida y decide.5 minutos
ID tokenLa aplicación clienteSaber quién inició sesión. No se envía a las APIs.5 minutos
Refresh tokenLa aplicación clientePedir un access token nuevo sin volver a pedir la contraseña.Ligado a la sesión SSO (30 min de inactividad)

En Keycloak los tres son JWT, pero el refresh token está firmado con una clave que solo conoce Keycloak: trátalo como un valor opaco, no lo leas ni lo valides tú.

Anatomía de un JWT

Un JWT (RFC 7519) son tres trozos en base64url separados por puntos: header.payload.firma. Este es un access token de ejemplo con exactamente los mismos claims que Keycloak 26.8 emite para ana en el realm tienda (IDs y firma inventados):

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlhxM20tZGVtbyJ9.eyJleHAiOjE3OTE1NDAzMDAsImlhdCI6MTc5MTU0MDAwMCwianRpIjoib25ydGFjOjJmNmIxYzBlLWRlbW8iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL3RpZW5kYSIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI3YzFkNWMyZS0xYjdlLTRiOGEtOWQzZS01ZjZhN2I4YzlkMGUiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0aWVuZGEtd2ViIiwic2lkIjoiYTFiMmMzZDQtZGVtbyIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiY2xpZW50ZSIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJkZWZhdWx0LXJvbGVzLXRpZW5kYSJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IkFuYSBDbGllbnRlIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYW5hIiwiZ2l2ZW5fbmFtZSI6IkFuYSIsImZhbWlseV9uYW1lIjoiQ2xpZW50ZSIsImVtYWlsIjoiYW5hQHRpZW5kYS50ZXN0In0.ZmlybWEtZGUtZWplbXBsby1uby12YWxpZGE

Decodificado, el header dice cómo está firmado y con qué clave:

{ "alg": "RS256", "typ": "JWT", "kid": "Xq3m-demo" }

Y el payload contiene los claims:

{
  "exp": 1791540300,
  "iat": 1791540000,
  "jti": "onrtac:2f6b1c0e-demo",
  "iss": "http://localhost:8080/realms/tienda",
  "aud": "account",
  "sub": "7c1d5c2e-1b7e-4b8a-9d3e-5f6a7b8c9d0e",
  "typ": "Bearer",
  "azp": "tienda-web",
  "sid": "a1b2c3d4-demo",
  "acr": "1",
  "realm_access": { "roles": ["cliente", "offline_access", "uma_authorization", "default-roles-tienda"] },
  "resource_access": { "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] } },
  "scope": "openid email profile",
  "email_verified": true,
  "name": "Ana Cliente",
  "preferred_username": "ana",
  "given_name": "Ana",
  "family_name": "Cliente",
  "email": "ana@tienda.test"
}
ClaimSignificadoQuién lo comprueba
issIssuer: quién emitió el token. En Keycloak, la URL del realm.Toda validación. Debe coincidir exactamente.
subSubject: ID estable del usuario (un UUID). Úsalo como clave, no el username.Tu código, para identificar al usuario.
audAudience: para quién es el token.La API: debe estar ella en la lista (lección 5).
azpAuthorized party: el client que pidió el token.Opcional; útil para saber qué app llama.
exp / iatCaducidad y emisión (segundos Unix).Toda validación (con un margen pequeño de reloj).
typTipo según Keycloak: Bearer, ID o Refresh.Evita aceptar un ID token como access token.
scopeScopes concedidos, separados por espacios.La API, para permisos gruesos (lección 6).
realm_access.rolesRoles de realm del usuario.La API, para autorizar (lección 6).
resource_access.<client>.rolesRoles de un client concreto.La API, si usa roles de client.
sidID de la sesión SSO en Keycloak.Logout (lección 4).
Decodificar no es validar

Base64url no cifra nada: cualquiera puede leer un JWT y cualquiera puede fabricar uno con el payload que quiera. Lo único que lo hace fiable es la firma, y solo si la verificas tú con la clave pública correcta.

Firma y JWKS

Keycloak firma con una clave privada del realm (por defecto RSA, RS256). Publica las claves públicas correspondientes en el endpoint JWKS (…/protocol/openid-connect/certs). El kid del header indica cuál de ellas usar. Validar un access token significa, como mínimo:

  1. Buscar en el JWKS la clave con ese kid y verificar la firma.
  2. Comprobar que alg es uno de los esperados (nunca none).
  3. Comprobar iss (exacto), aud (contiene tu API) y exp (no caducado).
  4. Solo entonces, leer roles y scopes para autorizar.

No lo programarás a mano: en la lección 5 go-oidc hará los pasos 1 a 3, incluida la caché de claves y su rotación.

Los flujos (grant types)

Un flujo es la forma de conseguir un token. Cada parte de la tienda usa uno distinto:

FlujoCuándoEn la tienda
Authorization Code + PKCEHay una persona delante de un navegador o una app.tienda-web · lección 3
Refresh TokenRenovar el access token sin molestar al usuario.tienda-web · lección 4
Client CredentialsUn servicio actúa en su propio nombre, sin usuario.facturacion · lección 7
Token Exchange (RFC 8693)Un servicio cambia un token por otro para llamar a un tercero.facturacion · lección 8
Device AuthorizationDispositivos sin teclado cómodo (TV, CLI).No lo usamos.
Implicit, PasswordObsoletos: exponen tokens o contraseñas. Desaconsejados por la buena práctica de seguridad de OAuth (RFC 9700) y eliminados en OAuth 2.1.Nunca.

Authorization Code + PKCE, paso a paso

Es el flujo que hará tienda-web cuando ana pulse «Entrar». Parece largo, pero cada paso tiene un motivo:

Diagrama de secuencia del flujo Authorization Code con PKCE entre navegador, tienda-web, Keycloak y api-pedidos Navegador tienda-web Keycloak api-pedidos 1 GET /login 2 genera state, nonce y code_verifier (secreto) 3 302 → Keycloak /auth state · nonce · code_challenge 4 GET /realms/tienda/…/auth?… 5 página de login de Keycloak 6 usuario + contraseña (solo los ve Keycloak) 7 302 → localhost:3000/callback ?code=…&state=… 8 GET /callback?code&state 9 POST /token (servidor→servidor) code + code_verifier + client_secret 10 access · ID · refresh token 11 valida ID token (firma, iss, aud, nonce) → crea sesión 12 GET /pedidos Authorization: Bearer <access> 13 200 OK · pedidos de ana
Flechas continuas: peticiones · discontinuas: respuestas · naranja: canal trasero, el navegador no lo ve.
PiezaQué esQué ataque evita
redirect_uriAdónde devuelve Keycloak al usuario (paso 7). Debe estar registrada en el client.Que un atacante reciba el code en su propia web.
stateValor aleatorio que la app guarda (cookie) y Keycloak devuelve sin tocar (pasos 3 y 8).CSRF: que te «inicien sesión» con una respuesta que tú no pediste.
nonceValor aleatorio que Keycloak copia dentro del ID token (paso 11).Reutilizar (replay) un ID token robado.
code_verifier / code_challengePKCE: la app inventa un secreto, envía su hash SHA-256 al principio y el secreto original al canjear el código.Que alguien que intercepte el code lo canjee por tokens.
client_secretContraseña del client confidencial; solo viaja por el canal trasero (paso 9).Que otra app se haga pasar por tienda-web.

El code viaja por el navegador (canal frontal) pero no sirve de nada sin el code_verifier y el secreto, que solo conoce el servidor de tienda-web. Los tokens llegan por el canal trasero (pasos 9–10) y nunca pasan por el navegador: este solo recibe una cookie de sesión.

Client confidencial frente a público

Un client confidencial se ejecuta en un servidor y puede guardar un secreto (tienda-web, facturacion). Un client público se ejecuta en el dispositivo del usuario (una SPA, una app móvil o una CLI) y no puede. Para los públicos, PKCE es la única protección del código; para los confidenciales es una capa extra que también exigiremos.

Los endpoints de un realm de Keycloak

Todas las URLs cuelgan del issuer http://localhost:8080/realms/tienda:

EndpointRutaLo usa
Descubrimiento/.well-known/openid-configurationLas librerías, al arrancar.
Autorización/protocol/openid-connect/authEl navegador (login).
Token/protocol/openid-connect/tokenLos clients, por el canal trasero.
JWKS/protocol/openid-connect/certsQuien valida firmas.
Userinfo/protocol/openid-connect/userinfoClients que quieren datos del usuario.
Logout/protocol/openid-connect/logoutEl navegador, al cerrar sesión.
Introspección/protocol/openid-connect/token/introspectUna API que prefiere preguntar a Keycloak en vez de validar ella.

En la lección 2 escribirás un programa Go que lee el documento de descubrimiento y muestra estas URLs.

Scopes y roles: no son lo mismo

  • Un scope lo pide el client y limita lo que el token permite: «esta app quiere leer pedidos» (pedidos:leer).
  • Un rol lo tiene el usuario y dice qué puede hacer él: «ana es cliente», «carlos es admin».

Lo que alguien puede hacer de verdad es la intersección: carlos es admin, pero si la app que usa solo obtuvo el scope de lectura, la API no debería dejarle borrar. Lo pondrás en práctica en la lección 6.

Ejercicios

1. Elige el flujo · fácil

¿Qué flujo usarías en cada caso?

  1. Un cron nocturno en Go que llama a api-pedidos para generar un informe.
  2. Una SPA en React que muestra los pedidos de quien inicia sesión.
  3. tienda-web necesita un access token nuevo porque el anterior caducó hace 10 segundos.
  4. Una CLI tienda login que se ejecuta en un servidor sin navegador.
Ver solución
  1. Client Credentials: no hay usuario; el cron actúa en su propio nombre con su service account.
  2. Authorization Code + PKCE con un client público (la SPA no puede guardar secretos). Mejor aún: un backend-for-frontend que haga el flujo confidencial.
  3. Refresh Token: canjea el refresh token sin intervención del usuario.
  4. Device Authorization: la CLI muestra un código y una URL; el usuario la abre en otro dispositivo.

2. Decodifica el token de ejemplo · fácil

Escribe un programa Go que reciba un JWT como argumento e imprima el header y el payload formateados. Úsalo con el token de ejemplo y responde:

  1. ¿Qué usuario es y qué roles de realm tiene?
  2. ¿Qué client pidió el token?
  3. ¿Cuántos segundos dura?
  4. ¿Aceptaría api-pedidos este token si exige aud: api-pedidos?

Pista: strings.Split, base64.RawURLEncoding y json.MarshalIndent.

Ver solución
package main

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"strings"
)

// Uso: go run . <token>
// OJO: solo DECODIFICA; no verifica la firma. Nunca confíes en un token así.
func main() {
	if len(os.Args) != 2 {
		log.Fatal("uso: go run . <token>")
	}
	parts := strings.Split(os.Args[1], ".")
	if len(parts) != 3 {
		log.Fatalf("un JWT tiene 3 partes, este tiene %d", len(parts))
	}
	for i, name := range []string{"header", "payload"} {
		raw, err := base64.RawURLEncoding.DecodeString(parts[i])
		if err != nil {
			log.Fatalf("%s: %v", name, err)
		}
		var v map[string]any
		if err := json.Unmarshal(raw, &v); err != nil {
			log.Fatalf("%s: %v", name, err)
		}
		pretty, _ := json.MarshalIndent(v, "", "  ")
		fmt.Printf("== %s ==\n%s\n", name, pretty)
	}
}
  1. preferred_username: ana (sub 7c1d…9d0e); roles cliente, default-roles-tienda, offline_access, uma_authorization.
  2. azp: tienda-web.
  3. exp − iat = 1791540300 − 1791540000 = 300 s: los 5 minutos por defecto de Keycloak.
  4. No: aud solo contiene account. Keycloak no añade tu API a la audiencia por arte de magia; en la lección 5 configurarás un audience mapper.

3. Calcula un par PKCE · media

Según RFC 7636, el code_verifier es una cadena aleatoria de 43 a 128 caracteres y code_challenge = BASE64URL(SHA256(code_verifier)) sin relleno =. Escribe un programa que genere ambos.

Ver solución
package main

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
)

func main() {
	// 1. code_verifier: 32 bytes aleatorios en base64url sin relleno = 43 caracteres.
	b := make([]byte, 32)
	rand.Read(b) // desde Go 1.24 nunca devuelve error
	verifier := base64.RawURLEncoding.EncodeToString(b)

	// 2. code_challenge = BASE64URL(SHA256(verifier))
	sum := sha256.Sum256([]byte(verifier))
	challenge := base64.RawURLEncoding.EncodeToString(sum[:])

	fmt.Println("code_verifier: ", verifier, len(verifier))
	fmt.Println("code_challenge:", challenge)
}

En la lección 3 no lo harás a mano: golang.org/x/oauth2 trae oauth2.GenerateVerifier(), oauth2.S256ChallengeOption(v) para la URL de login y oauth2.VerifierOption(v) para el canje del código.

4. Piensa como atacante · media

Una extensión maliciosa del navegador lee la URL del paso 7 y se queda con el code. Intenta canjearlo en el endpoint de token antes que tienda-web. ¿Qué le falta? ¿Y si además supiera el client_secret?

Ver solución

Le faltan el client_secret (client confidencial) y el code_verifier, que nunca salió del servidor de tienda-web. Aunque robara el secreto, Keycloak rechazaría el canje. Esta es la respuesta real de Keycloak 26.8 si falta el verifier:

{"error":"invalid_grant","error_description":"PKCE code verifier not specified"}

Con un verifier inventado también falla, porque SHA256(verifier) no coincide con el challenge del paso 3. Además, cada código es de un solo uso y caduca en segundos.

Errores comunes

diseño Enviar el ID token a la API

El ID token es para la app cliente; su aud es el client (tienda-web), no la API. Una API bien hecha lo rechazará, y una mal hecha lo aceptará y abrirá un agujero. Arreglo: a la API siempre el access token.

seguridad Leer claims sin verificar la firma

Decodificar el payload (ejercicio 2) y usar sus roles sin verificar la firma permite a cualquiera fabricar un token de «admin». Arreglo: usa siempre un verificador (go-oidc) y lee los claims del token ya verificado.

seguridad No comprobar aud

Un token válido para otra aplicación del mismo realm tiene firma e issuer correctos. Si tu API no comprueba la audiencia, lo aceptará. Arreglo: exigir que aud contenga el ID de tu API (lección 5).

confusión Mezclar state y nonce

state lo compruebas en el callback (viene en la URL); nonce lo compruebas dentro del ID token. Se necesitan los dos y no son intercambiables.

token is expired Relojes desincronizados

Si el reloj de tu máquina (o de WSL tras suspender el portátil) va adelantado o atrasado, tokens recién emitidos parecen caducados o «del futuro». Arreglo: sincroniza la hora (hora automática en Windows y macOS; en WSL, sudo hwclock -s; en Linux, timedatectl set-ntp true) y permite un margen pequeño al validar.

diseño Usar preferred_username como identificador

El nombre de usuario o el email pueden cambiar; sub no. Arreglo: guarda los pedidos asociados a sub.

Resumen

Keycloak autentica y emite tokens firmados. La app recibe un ID token (quién eres) y un access token (qué puede hacer en tu nombre), que envía a la API. La API verifica la firma con el JWKS y comprueba iss, aud y exp antes de mirar roles. Para usuarios se usa Authorization Code + PKCE; para servicios, Client Credentials.