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.
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,nonceycode_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: 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 OAuth2 | Qué es | En la tienda |
|---|---|---|
| Resource owner | El dueño de los datos; normalmente una persona. | ana, carlos |
| Client | La aplicación que quiere acceder a los datos. | tienda-web, facturacion |
| Authorization server | Autentica y emite tokens. | Keycloak (realm tienda) |
| Resource server | La 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-configurationcon 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
| Token | Para quién | Para qué | Vida por defecto en Keycloak |
|---|---|---|---|
| Access token | La API (resource server) | Se envía en Authorization: Bearer …. La API lo valida y decide. | 5 minutos |
| ID token | La aplicación cliente | Saber quién inició sesión. No se envía a las APIs. | 5 minutos |
| Refresh token | La aplicación cliente | Pedir 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"
}
| Claim | Significado | Quién lo comprueba |
|---|---|---|
iss | Issuer: quién emitió el token. En Keycloak, la URL del realm. | Toda validación. Debe coincidir exactamente. |
sub | Subject: ID estable del usuario (un UUID). Úsalo como clave, no el username. | Tu código, para identificar al usuario. |
aud | Audience: para quién es el token. | La API: debe estar ella en la lista (lección 5). |
azp | Authorized party: el client que pidió el token. | Opcional; útil para saber qué app llama. |
exp / iat | Caducidad y emisión (segundos Unix). | Toda validación (con un margen pequeño de reloj). |
typ | Tipo según Keycloak: Bearer, ID o Refresh. | Evita aceptar un ID token como access token. |
scope | Scopes concedidos, separados por espacios. | La API, para permisos gruesos (lección 6). |
realm_access.roles | Roles de realm del usuario. | La API, para autorizar (lección 6). |
resource_access.<client>.roles | Roles de un client concreto. | La API, si usa roles de client. |
sid | ID de la sesión SSO en Keycloak. | Logout (lección 4). |
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:
- Buscar en el JWKS la clave con ese
kidy verificar la firma. - Comprobar que
alges uno de los esperados (nuncanone). - Comprobar
iss(exacto),aud(contiene tu API) yexp(no caducado). - 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:
| Flujo | Cuándo | En la tienda |
|---|---|---|
| Authorization Code + PKCE | Hay una persona delante de un navegador o una app. | tienda-web · lección 3 |
| Refresh Token | Renovar el access token sin molestar al usuario. | tienda-web · lección 4 |
| Client Credentials | Un 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 Authorization | Dispositivos sin teclado cómodo (TV, CLI). | No lo usamos. |
| Obsoletos: 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:
| Pieza | Qué es | Qué ataque evita |
|---|---|---|
redirect_uri | Adónde devuelve Keycloak al usuario (paso 7). Debe estar registrada en el client. | Que un atacante reciba el code en su propia web. |
state | Valor 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. |
nonce | Valor aleatorio que Keycloak copia dentro del ID token (paso 11). | Reutilizar (replay) un ID token robado. |
code_verifier / code_challenge | PKCE: 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_secret | Contraseñ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.
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:
| Endpoint | Ruta | Lo usa |
|---|---|---|
| Descubrimiento | /.well-known/openid-configuration | Las librerías, al arrancar. |
| Autorización | /protocol/openid-connect/auth | El navegador (login). |
| Token | /protocol/openid-connect/token | Los clients, por el canal trasero. |
| JWKS | /protocol/openid-connect/certs | Quien valida firmas. |
| Userinfo | /protocol/openid-connect/userinfo | Clients que quieren datos del usuario. |
| Logout | /protocol/openid-connect/logout | El navegador, al cerrar sesión. |
| Introspección | /protocol/openid-connect/token/introspect | Una 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 esadmin».
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?
- Un cron nocturno en Go que llama a
api-pedidospara generar un informe. - Una SPA en React que muestra los pedidos de quien inicia sesión.
tienda-webnecesita un access token nuevo porque el anterior caducó hace 10 segundos.- Una CLI
tienda loginque se ejecuta en un servidor sin navegador.
Ver solución
- Client Credentials: no hay usuario; el cron actúa en su propio nombre con su service account.
- 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.
- Refresh Token: canjea el refresh token sin intervención del usuario.
- 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:
- ¿Qué usuario es y qué roles de realm tiene?
- ¿Qué client pidió el token?
- ¿Cuántos segundos dura?
- ¿Aceptaría
api-pedidoseste token si exigeaud: 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)
}
}
preferred_username: ana(sub7c1d…9d0e); rolescliente,default-roles-tienda,offline_access,uma_authorization.azp: tienda-web.exp − iat = 1791540300 − 1791540000 = 300s: los 5 minutos por defecto de Keycloak.- No:
audsolo contieneaccount. 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.
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.