Autorización por roles y scopes
La API ya sabe quién llama. Ahora decide qué puede hacer: comprar, ver todos los pedidos o cambiar su estado, según los roles del usuario y los scopes que la aplicación obtuvo.
En este capítulo
- Trabajas en
- api-pedidos: quién puede hacer qué (roles y scopes). tienda-web: comprar y la página Admin.
- Carpeta
tienda/pasos/paso-06· realm nuevo- Archivos
- Nuevos:
internal/apiauth/authz.go,internal/web/compras.go, la plantillaadmin.html; cambianinternal/api/api.goyinternal/auth/auth.go· ver todos los cambios del paso. - En marcha
- Keycloak del paso 06, api y web.
- Comprueba
go -C tools/comprobar run . -paso 6 -servicios(desdecourse/)
Al terminar sabrás
- Distinguir roles (lo que puede hacer el usuario) de scopes (lo que se le permitió a la aplicación).
- Crear un scope opcional en Keycloak y pedirlo desde Go.
- Escribir middlewares
RequireRoleyRequireScopey combinarlos por ruta. - Responder
401,403 forbiddeno403 insufficient_scopesegún el caso. - Separar la autorización de la interfaz (qué enseñar) de la de la API (qué permitir).
Roles frente a scopes
| Rol | Scope | |
|---|---|---|
| Qué dice | Lo que el usuario puede hacer | Lo que la aplicación está autorizada a hacer en su nombre |
| Quién lo decide | Un administrador, asignándolo al usuario (o a su grupo) | La aplicación lo pide; Keycloak lo concede si el client lo tiene (y, si hace falta, el usuario consiente) |
| En el token | realm_access.roles (o resource_access.<client>.roles) | scope: "openid email pedidos:escribir profile" |
| En la tienda | cliente, admin | pedidos:escribir |
El permiso efectivo es la intersección. carlos es admin, pero un token de la CLI pedido sin pedidos:escribir no le deja cambiar pedidos, aunque él podría. Así, una herramienta de solo lectura no puede escribir aunque la use un administrador.
Las reglas de api-pedidos:
| Endpoint | Rol | Scope |
|---|---|---|
GET /pedidos | cliente o admin | — |
GET /pedidos/{id} | dueño del pedido, o admin | — |
POST /pedidos (comprar) | cliente o admin | pedidos:escribir |
GET /admin/pedidos | admin | — |
PATCH /admin/pedidos/{id} | admin | pedidos:escribir |
1. Reimportar el realm
cd tienda/pasos/paso-05/infra && docker compose down -v
cd ../../paso-06/infra && docker compose up -d
Cambios respecto al paso 5: el client scope pedidos:escribir, asignado como Optional a tienda-web y tienda-cli, y un mapper en tienda-web que pone los roles en el ID token (el del ejercicio 2 de la lección 3).
2. Un scope opcional
{
"name": "pedidos:escribir",
"description": "Permite crear pedidos y cambiar su estado",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Crear y modificar pedidos"
}
}
include.in.token.scope: true: si se concede, aparece en el claimscope. Es lo que mira la API.consent.screen.text: el texto que verá el usuario si el client pide consentimiento.
La diferencia entre Default y Optional: un scope Default va siempre en los tokens del client; uno Optional solo si la aplicación lo pide en el parámetro scope. api-pedidos (la audiencia) es Default: toda llamada lo necesita. pedidos:escribir es Optional: solo lo pide quien va a escribir.
api-pedidos Default, pedidos:escribir Optional.tienda-web lo pide siempre, porque su interfaz permite comprar:
// pedidos:escribir es un scope opcional: si no se pide, no llega al token.
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "pedidos:escribir"},
La CLI no lo pide salvo que se lo digas: go run ./cmd/token -scope pedidos:escribir. Como tienda-cli usa el flujo de dispositivo, Keycloak enseña al usuario qué está concediendo:
consent.screen.text del scope: «Crear y modificar pedidos».3. Middlewares de autorización
Van después del middleware de la lección 5: necesitan el Principal ya verificado. Cada uno es una función que envuelve un handler, para poder combinarlos:
package apiauth
import (
"fmt"
"net/http"
"slices"
"tienda/internal/jsonhttp"
)
// HasRole indica si el token trae el rol de realm r.
func (p *Principal) HasRole(r string) bool { return slices.Contains(p.Roles, r) }
// HasScope indica si el token trae el scope s.
func (p *Principal) HasScope(s string) bool { return slices.Contains(p.Scopes, s) }
// RequireRole deja pasar solo si el usuario tiene alguno de los roles.
// Va siempre DESPUÉS de Middleware (necesita el Principal en el contexto).
func RequireRole(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := FromContext(r.Context())
if p == nil || !slices.ContainsFunc(roles, p.HasRole) {
jsonhttp.Error(w, http.StatusForbidden, "forbidden",
fmt.Sprintf("hace falta uno de estos roles: %v", roles))
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireScope deja pasar solo si el token trae el scope. Si no, responde
// 403 insufficient_scope (RFC 6750) indicando qué scope pedir.
func RequireScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := FromContext(r.Context())
if p == nil || !p.HasScope(scope) {
w.Header().Set("WWW-Authenticate",
fmt.Sprintf(`Bearer realm="api-pedidos", error="insufficient_scope", scope=%q`, scope))
jsonhttp.Error(w, http.StatusForbidden, "insufficient_scope",
fmt.Sprintf("el token no incluye el scope %q", scope))
return
}
next.ServeHTTP(w, r)
})
}
}
| Respuesta | Significa | El cliente debería… |
|---|---|---|
401 + invalid_token | No sé quién eres: token ausente, inválido o caducado. | Conseguir un token nuevo (refresh o login). |
403 + insufficient_scope | Sé quién eres, pero tu aplicación no pidió el permiso. | Pedir un token con el scope que indica WWW-Authenticate. |
403 + forbidden | Sé quién eres, y tú no puedes hacer esto. | Nada: un token nuevo no lo cambia (salvo que te asignen el rol). |
4. Las rutas
Con protect, cada ruta declara en una línea lo que exige. Leer Register es leer la tabla de permisos:
// Register añade las rutas de la API. Cada ruta declara, de un vistazo,
// qué hace falta para llamarla: token válido + roles + scope.
func Register(mux *http.ServeMux, v *apiauth.Verifier, store *pedidos.Store) {
h := &handlers{store: store}
cliente := apiauth.RequireRole("cliente", "admin")
admin := apiauth.RequireRole("admin")
escribir := apiauth.RequireScope("pedidos:escribir")
mux.Handle("GET /pedidos", protect(v, h.list, cliente))
mux.Handle("GET /pedidos/{id}", protect(v, h.get, cliente))
mux.Handle("POST /pedidos", protect(v, h.create, cliente, escribir))
mux.Handle("GET /admin/pedidos", protect(v, h.listAll, admin))
mux.Handle("PATCH /admin/pedidos/{id}", protect(v, h.setStatus, admin, escribir))
}
// protect encadena: token válido (v.Middleware) → cada comprobación → handler.
func protect(v *apiauth.Verifier, h http.HandlerFunc, checks ...func(http.Handler) http.Handler) http.Handler {
var next http.Handler = h
for i := len(checks) - 1; i >= 0; i-- {
next = checks[i](next)
}
return v.Middleware(next)
}
Y dentro de los handlers, la regla de oro: los datos de seguridad salen del token, no del cuerpo de la petición. Al crear un pedido, el dueño es el sub del token y el precio sale del catálogo; el cliente solo dice qué producto y cuántas unidades.
// create registra un pedido a nombre de quien llama. El dueño sale del token
// y el precio del catálogo: el cuerpo solo dice qué y cuánto.
func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
p := apiauth.FromContext(r.Context())
var body struct {
Producto string `json:"producto"`
Cantidad int `json:"cantidad"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
return
}
o, err := h.store.Create(p.Subject, body.Producto, body.Cantidad)
if err != nil {
jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
jsonhttp.Write(w, http.StatusCreated, o)
}
Ver internal/api/api.go, internal/pedidos/pedidos.go y catalogo.go completos
// Package api contiene los handlers HTTP de api-pedidos.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"tienda/internal/apiauth"
"tienda/internal/jsonhttp"
"tienda/internal/pedidos"
)
type handlers struct {
store *pedidos.Store
}
// Register añade las rutas de la API. Cada ruta declara, de un vistazo,
// qué hace falta para llamarla: token válido + roles + scope.
func Register(mux *http.ServeMux, v *apiauth.Verifier, store *pedidos.Store) {
h := &handlers{store: store}
cliente := apiauth.RequireRole("cliente", "admin")
admin := apiauth.RequireRole("admin")
escribir := apiauth.RequireScope("pedidos:escribir")
mux.Handle("GET /pedidos", protect(v, h.list, cliente))
mux.Handle("GET /pedidos/{id}", protect(v, h.get, cliente))
mux.Handle("POST /pedidos", protect(v, h.create, cliente, escribir))
mux.Handle("GET /admin/pedidos", protect(v, h.listAll, admin))
mux.Handle("PATCH /admin/pedidos/{id}", protect(v, h.setStatus, admin, escribir))
}
// protect encadena: token válido (v.Middleware) → cada comprobación → handler.
func protect(v *apiauth.Verifier, h http.HandlerFunc, checks ...func(http.Handler) http.Handler) http.Handler {
var next http.Handler = h
for i := len(checks) - 1; i >= 0; i-- {
next = checks[i](next)
}
return v.Middleware(next)
}
// list devuelve los pedidos de quien llama, identificado por el «sub» del token.
func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
p := apiauth.FromContext(r.Context())
jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.ByOwner(p.Subject)})
}
// get devuelve un pedido si pertenece a quien llama (o si es admin). Si es de
// otro usuario respondemos 404, no 403: así no revelamos qué pedidos existen.
func (h *handlers) get(w http.ResponseWriter, r *http.Request) {
p := apiauth.FromContext(r.Context())
id, ok := pathID(w, r)
if !ok {
return
}
o, found := h.store.Get(id)
if !found || (o.Owner != p.Subject && !p.HasRole("admin")) {
jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
return
}
jsonhttp.Write(w, http.StatusOK, o)
}
// create registra un pedido a nombre de quien llama. El dueño sale del token
// y el precio del catálogo: el cuerpo solo dice qué y cuánto.
func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
p := apiauth.FromContext(r.Context())
var body struct {
Producto string `json:"producto"`
Cantidad int `json:"cantidad"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
return
}
o, err := h.store.Create(p.Subject, body.Producto, body.Cantidad)
if err != nil {
jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
jsonhttp.Write(w, http.StatusCreated, o)
}
// listAll devuelve todos los pedidos (solo admin).
func (h *handlers) listAll(w http.ResponseWriter, r *http.Request) {
jsonhttp.Write(w, http.StatusOK, map[string]any{"pedidos": h.store.All()})
}
// setStatus cambia el estado de un pedido (solo admin con pedidos:escribir).
func (h *handlers) setStatus(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "JSON inválido")
return
}
o, err := h.store.SetStatus(id, body.Status)
switch {
case errors.Is(err, pedidos.ErrNotFound):
jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
case err != nil:
jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
default:
jsonhttp.Write(w, http.StatusOK, o)
}
}
// pathID lee {id} de la ruta; si no es un número, responde 400.
func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", "el id debe ser un número")
return 0, false
}
return id, true
}
// Package pedidos es el dominio de api-pedidos: los pedidos y un almacén en
// memoria con datos de ejemplo.
package pedidos
import (
"errors"
"fmt"
"slices"
"sort"
"sync"
"time"
)
// IDs fijos de los usuarios del realm (ver infra/realm/tienda-realm.json).
// Los pedidos se asocian al «sub» del token, nunca al nombre de usuario.
const (
AnaID = "00000000-0000-4000-8000-0000000000a1"
CarlosID = "00000000-0000-4000-8000-0000000000c1"
)
// ErrNotFound indica que el pedido no existe.
var ErrNotFound = errors.New("pedido no encontrado")
// Order es un pedido.
type Order struct {
ID int `json:"id"`
Owner string `json:"owner"` // sub del cliente
Items string `json:"items"`
Total float64 `json:"total"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// Statuses son los estados válidos de un pedido.
var Statuses = []string{"Pendiente", "Enviado", "Entregado", "Cancelado"}
// Store guarda los pedidos en memoria, seguro para uso concurrente.
type Store struct {
mu sync.Mutex
orders map[int]Order
nextID int
}
// NewStore crea un almacén con los pedidos de ejemplo de ana y carlos.
func NewStore() *Store {
day := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
s := &Store{orders: make(map[int]Order), nextID: 1004}
for _, o := range []Order{
{1001, AnaID, "Gopher de peluche ×1, Pegatinas OIDC ×2", 29.88, "Enviado", day},
{1002, AnaID, "Taza «go fmt» ×1", 9.50, "Pendiente", day.Add(48 * time.Hour)},
{1003, CarlosID, "Camiseta Keycloak ×2", 30.00, "Entregado", day.Add(24 * time.Hour)},
} {
s.orders[o.ID] = o
}
return s
}
// ByOwner devuelve los pedidos de un usuario, ordenados por número.
func (s *Store) ByOwner(sub string) []Order {
return s.filter(func(o Order) bool { return o.Owner == sub })
}
// All devuelve todos los pedidos, ordenados por número.
func (s *Store) All() []Order {
return s.filter(func(Order) bool { return true })
}
func (s *Store) filter(keep func(Order) bool) []Order {
s.mu.Lock()
defer s.mu.Unlock()
out := []Order{}
for _, o := range s.orders {
if keep(o) {
out = append(out, o)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Create registra un pedido nuevo de owner, calculando el total con los
// precios del catálogo.
func (s *Store) Create(owner, productID string, qty int) (Order, error) {
p, ok := FindProduct(productID)
if !ok {
return Order{}, fmt.Errorf("producto desconocido %q", productID)
}
if qty < 1 || qty > 10 {
return Order{}, fmt.Errorf("la cantidad debe estar entre 1 y 10")
}
s.mu.Lock()
defer s.mu.Unlock()
o := Order{
ID: s.nextID,
Owner: owner,
Items: fmt.Sprintf("%s ×%d", p.Name, qty),
Total: p.Price * float64(qty),
Status: "Pendiente",
CreatedAt: time.Now().UTC(),
}
s.orders[o.ID] = o
s.nextID++
return o, nil
}
// SetStatus cambia el estado de un pedido.
func (s *Store) SetStatus(id int, status string) (Order, error) {
if !slices.Contains(Statuses, status) {
return Order{}, fmt.Errorf("estado inválido %q", status)
}
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.orders[id]
if !ok {
return Order{}, ErrNotFound
}
o.Status = status
s.orders[id] = o
return o, nil
}
// Get devuelve un pedido por su número.
func (s *Store) Get(id int) (Order, bool) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.orders[id]
return o, ok
}
package pedidos
// Product es un artículo del catálogo.
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Desc string `json:"desc"`
Price float64 `json:"price"`
}
// Catalog es el catálogo de la tienda. tienda-web lo muestra y api-pedidos lo
// usa para calcular los totales: el precio nunca lo decide quien compra.
var Catalog = []Product{
{"gopher", "Gopher de peluche", "El compañero ideal para depurar.", 19.90},
{"taza", "Taza «go fmt»", "Formatea tu café automáticamente.", 9.50},
{"camiseta", "Camiseta Keycloak", "Algodón 100 %, talla única de realm.", 15.00},
{"pegatinas", "Pegatinas OIDC", "Pack de 10: iss, sub, aud, exp…", 4.99},
}
// FindProduct busca un artículo por su id.
func FindProduct(id string) (Product, bool) {
for _, p := range Catalog {
if p.ID == id {
return p, true
}
}
return Product{}, false
}
5. La matriz de permisos, probada
Arranca la API (go run ./cmd/api) y pide tokens con y sin el scope. Estos son los resultados reales contra Keycloak 26.8:
TOKEN=$(go run ./cmd/token) # sin pedidos:escribir
TOKEN=$(go run ./cmd/token -scope pedidos:escribir) # con pedidos:escribir
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"producto":"taza","cantidad":2}' localhost:8081/pedidos
En PowerShell (Windows, probado)
$TOKEN = go run ./cmd/token -scope pedidos:escribir
# El JSON en un archivo: así funciona igual en PowerShell 5.1 y 7
Set-Content -Path pedido.json -Value '{"producto":"taza","cantidad":2}'
curl.exe -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" `
--data "@pedido.json" localhost:8081/pedidos
| Quién / token | Petición | Resultado |
|---|---|---|
| ana, sin scope | GET /pedidos | 200 sus pedidos |
| ana, sin scope | POST /pedidos | 403 insufficient_scope: «el token no incluye el scope "pedidos:escribir"» |
| ana, sin scope | GET /admin/pedidos | 403 forbidden: «hace falta uno de estos roles: [admin]» |
| ana, con scope | POST /pedidos taza ×2 | 201 pedido #1004, total 19 |
| ana, con scope | POST con "total": 0.01 | 201 con total 9.5: el campo se ignora |
| ana, con scope | POST producto "yate" | 400 «producto desconocido "yate"» |
| ana, con scope | PATCH /admin/pedidos/1001 | 403 forbidden: el scope no suple al rol |
| carlos, sin scope | GET /admin/pedidos | 200 todos los pedidos |
| carlos, sin scope | PATCH /admin/pedidos/1001 | 403 insufficient_scope: el rol no suple al scope |
| carlos, con scope | PATCH /admin/pedidos/1001 Entregado | 200 |
| carlos, con scope | GET /pedidos/1001 (de ana) | 200: un admin ve cualquier pedido |
Www-Authenticate: Bearer realm="api-pedidos", error="insufficient_scope", scope="pedidos:escribir"
6. La tienda: comprar y administrar
tienda-web añade un botón Comprar en el catálogo y una página Admin. Para saber si mostrar el enlace «Admin», necesita los roles; los lee del ID token gracias a un mapper del client:
La página de administración no comprueba el rol: llama a la API y muestra lo que esta responda. El enlace solo se enseña a los admins, pero si ana escribe /admin en la barra de direcciones, es la API quien la para:
package web
import (
"errors"
"log"
"net/http"
"strconv"
"golang.org/x/oauth2"
"tienda/internal/auth"
"tienda/internal/pedidos"
"tienda/internal/pedidosclient"
"tienda/internal/session"
)
// apiToken devuelve la sesión y un access token vigente para llamar a la API.
// Si la sesión de Keycloak terminó, redirige al login (que volverá a «next»)
// y devuelve ok=false.
func (h *handlers) apiToken(w http.ResponseWriter, r *http.Request, next string) (*session.Session, *oauth2.Token, bool) {
sess, _ := h.auth.CurrentSession(r)
tok, err := h.auth.Token(r.Context(), sess, false)
if errors.Is(err, auth.ErrSessionEnded) {
http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther)
return nil, nil, false
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return nil, nil, false
}
return sess, tok, true
}
// comprar crea un pedido con un artículo del catálogo (botón «Comprar»).
func (h *handlers) comprar(w http.ResponseWriter, r *http.Request) {
_, tok, ok := h.apiToken(w, r, "%2F")
if !ok {
return
}
o, err := h.api.Create(r.Context(), tok, r.PostFormValue("producto"), 1)
if err != nil {
log.Printf("comprar: %v", err)
http.Redirect(w, r, "/?error="+errorCode(err), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/pedidos?nuevo="+strconv.Itoa(o.ID), http.StatusSeeOther)
}
// admin lista todos los pedidos. No comprobamos aquí el rol: si el usuario no
// es admin, api-pedidos responde 403 y lo mostramos. El enlace del menú solo
// se ve para admins, pero quien decide de verdad es la API.
func (h *handlers) admin(w http.ResponseWriter, r *http.Request) {
sess, tok, ok := h.apiToken(w, r, "%2Fadmin")
if !ok {
return
}
data := pageData{Active: "admin", Session: sess, Statuses: pedidos.Statuses}
if id := r.URL.Query().Get("ok"); id != "" {
data.Flash = "Pedido #" + id + " actualizado."
}
data.Error = errorMessage(r.URL.Query().Get("error"))
if orders, err := h.api.AllOrders(r.Context(), tok); err != nil {
log.Printf("admin: %v", err)
data.Error = errorMessage(errorCode(err))
} else {
data.Orders = orders
}
h.render(w, "admin.html", data)
}
// cambiarEstado cambia el estado de un pedido desde /admin.
func (h *handlers) cambiarEstado(w http.ResponseWriter, r *http.Request) {
_, tok, ok := h.apiToken(w, r, "%2Fadmin")
if !ok {
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "id inválido", http.StatusBadRequest)
return
}
if _, err := h.api.SetStatus(r.Context(), tok, id, r.PostFormValue("status")); err != nil {
log.Printf("cambiar estado: %v", err)
http.Redirect(w, r, "/admin?error="+errorCode(err), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?ok="+strconv.Itoa(id), http.StatusSeeOther)
}
// errorCode resume un error de la API en un código corto para la URL.
func errorCode(err error) string {
switch {
case errors.Is(err, pedidosclient.ErrForbidden):
return "forbidden"
case errors.Is(err, pedidosclient.ErrUnauthorized):
return "unauthorized"
default:
return "api"
}
}
// errorMessage traduce el código a un mensaje. Nunca mostramos en la página
// texto que venga de la URL: alguien podría enviar un enlace con un mensaje falso.
func errorMessage(code string) string {
switch code {
case "":
return ""
case "forbidden":
return "api-pedidos ha denegado la operación (403): tu token no tiene el rol o el scope necesarios."
case "unauthorized":
return "api-pedidos ha rechazado tu token (401). Vuelve a iniciar sesión."
default:
return "No se pudo completar la operación. Mira el log de tienda-web."
}
}
La interfaz usa los roles para decidir qué enseñar (un enlace, un botón). Eso es comodidad, no seguridad: cualquiera puede llamar a la API directamente con su token. La autoridad está en api-pedidos, que comprueba roles y scopes en cada petición.
Ejercicios
1. Sin permiso de escritura · fácil
Quita "pedidos:escribir" de los Scopes de tienda-web, reinicia, vuelve a iniciar sesión e intenta comprar. ¿Qué ves? ¿Qué cambia en Perfil?
Ver solución
El catálogo muestra «api-pedidos ha denegado la operación (403)…» y en el log de la API aparece POST /pedidos → 403. ana sigue siendo cliente, pero la aplicación ya no tiene el permiso de escritura: es justo la intersección de roles y scopes. En Perfil ves el ID token, que no lleva scope; el access token sí, y ya no incluye pedidos:escribir. Vuelve a dejarlo como estaba.
2. Un rol de client para la API · media
Los roles de realm sirven para todo el realm. Si un permiso solo tiene sentido para api-pedidos, es mejor un rol de client. Crea en Clients → api-pedidos → Roles el rol gestor, asígnalo a carlos y haz que las rutas de admin exijan gestor en lugar de admin.
Ver solución
Los roles de client van en resource_access.<client>.roles. Comprobado con Evaluate:
"resource_access": {
"account": { "roles": ["manage-account", "manage-account-links", "view-profile"] },
"api-pedidos": { "roles": ["gestor"] }
}
En apiauth.go, guarda la audiencia en el Verifier y lee los roles de esa API:
type Principal struct {
// ... campos existentes ...
APIRoles []string // resource_access.<audiencia>.roles: roles de client de esta API
}
type Verifier struct {
verifier *oidc.IDTokenVerifier
audience string
}
// en NewVerifier: &Verifier{verifier: …, audience: audience}
// en Verify, dentro del struct de claims:
ResourceAccess map[string]struct {
Roles []string `json:"roles"`
} `json:"resource_access"`
// y al construir el Principal:
APIRoles: c.ResourceAccess[v.audience].Roles,
// RequireAPIRole deja pasar solo si el usuario tiene el rol de client de esta API.
func RequireAPIRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := FromContext(r.Context())
if p == nil || !slices.Contains(p.APIRoles, role) {
jsonhttp.Error(w, http.StatusForbidden, "forbidden", "hace falta el rol de API "+role)
return
}
next.ServeHTTP(w, r)
})
}
}
Y en Register: admin := apiauth.RequireAPIRole("gestor"). Ventaja: el rol vive con la API a la que pertenece, y otros clients pueden tener su propio gestor sin pisarse.
3. Roles por grupo · fácil
Crea un grupo personal, asígnale el rol de realm admin (Groups → personal → Role mapping) y mete a ana en él. ¿Qué pasa en la tienda si ana ya tenía la sesión abierta? ¿Y si pulsa «Renovar ahora» en Perfil?
Ver solución
Los roles del grupo se heredan: el access token de ana incluirá admin en realm_access.roles (lo hemos comprobado con Evaluate). Pero los tokens ya emitidos no cambian.
- Al renovar, Keycloak calcula los roles de nuevo: el access token nuevo ya trae
admin(probado asignando el rol entre el login y el refresh). La API la dejará entrar en/admin/pedidos. - El enlace «Admin» de la cabecera no aparece hasta que ana vuelve a iniciar sesión: la app lee los roles del ID token al crear la sesión.
Deshaz el cambio al terminar. Los grupos se gestionan desde Go en el módulo 5.
4. Campos de más · media
Un cliente malicioso envía {"producto":"taza","cantidad":1,"total":0.01,"owner":"00000000-0000-4000-8000-0000000000c1"}. ¿Qué pedido se crea? ¿Cómo harías que la API rechazara esa petición en vez de ignorar los campos?
Ver solución
Se crea un pedido de ana por 9,50: encoding/json ignora los campos que no están en el struct, y aunque estuvieran, el handler usa el sub del token y el precio del catálogo. Para rechazarlo explícitamente:
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
dec.DisallowUnknownFields() // "total" u "owner" → error 400
if err := dec.Decode(&body); err != nil { /* 400 invalid_request */ }
Errores comunes
insufficient_scope El usuario tiene el rol, pero la API dice 403
El scope es Optional y la aplicación no lo pidió (o el usuario no lo consintió). Arreglo: añádelo al parámetro scope de la petición de autorización y vuelve a iniciar sesión: un refresh no añade scopes nuevos.
invalid_scope «Invalid scopes: openid pedidos:borrar»
Pediste un scope que no está asignado al client (ni como Default ni como Optional). Con Authorization Code, Keycloak vuelve a tu callback con ?error=invalid_scope; con el flujo de dispositivo, lo devuelve en el JSON. Arreglo: asigna el scope al client en Client scopes o corrige el nombre.
forbidden Acabo de asignar el rol y sigue sin funcionar
Los tokens ya emitidos no cambian. Arreglo: un refresh (Keycloak recalcula los roles) o un login nuevo. Si la interfaz lee los roles del ID token, solo un login nuevo actualiza el menú.
sin roles El ID token no trae roles
Por defecto Keycloak solo los pone en el access token. Arreglo: un mapper User Realm Role con Add to ID token activado (como el de este paso). No leas el access token en la app para eso: es para la API.
resource_access vacío El rol de client no aparece
El claim usa el clientId exacto (resource_access.api-pedidos). Comprueba que el rol se creó en el client correcto y que el client que pide el token tiene el scope roles (Default por defecto).
401 frente a 403 El cliente reintenta en bucle
Si la API responde 401 a una falta de permisos, el cliente pensará que el token es malo y lo renovará una y otra vez. Regla: 401 solo si el token es inválido; con un token válido sin permisos, 403.
api-pedidos valida localmente los access tokens (firma, iss, aud, exp, typ) y autoriza cada ruta con roles del usuario y scopes de la aplicación, combinando middlewares. Los datos sensibles (dueño, precio) salen del token y del servidor, nunca del cuerpo. tienda-web llama a la API en nombre del usuario y usa los roles solo para la interfaz. En el módulo 4, un servicio llamará a la API sin usuario: Client Credentials.



