Cambios de la lección 05
Todo lo que cambia en tienda/pasos/paso-05 respecto al paso anterior. Vuelve a la lección: 05. Middleware JWT con JWKS.
11 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.
| Archivo | Estado | Líneas |
|---|---|---|
cmd/api/main.go | nuevo | +81 −0 |
cmd/token/main.go | nuevo | +82 −0 |
cmd/web/main.go | modificado | +3 −1 |
infra/realm/tienda-realm.json | modificado | +800 −10 |
internal/api/api.go | nuevo | +45 −0 |
internal/apiauth/apiauth.go | nuevo | +131 −0 |
internal/jsonhttp/jsonhttp.go | nuevo | +20 −0 |
internal/pedidos/pedidos.go | nuevo | +68 −0 |
internal/pedidosclient/client.go | nuevo | +77 −0 |
internal/web/templates/pedidos.html | modificado | +4 −2 |
internal/web/web.go | modificado | +21 −27 |
cmd/api/main.go
@@ -0,0 +1,81 @@
+// Command api es api-pedidos: la API REST de pedidos, protegida con access
+// tokens de Keycloak.
+//
+// Uso (desde tienda/pasos/paso-05):
+//
+// go run ./cmd/api
+package main
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "tienda/internal/api"
+ "tienda/internal/apiauth"
+ "tienda/internal/pedidos"
+)
+
+func main() {
+ issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
+ audience := env("API_AUDIENCE", "api-pedidos") // client ID de la API en Keycloak
+ addr := env("ADDR", ":8081")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ verifier, err := apiauth.NewVerifier(ctx, issuer, audience)
+ if err != nil {
+ log.Fatal(err) // ¿Keycloak está arrancado?
+ }
+
+ mux := http.NewServeMux()
+ api.Register(mux, verifier, pedidos.NewStore())
+
+ srv := &http.Server{
+ Addr: addr,
+ Handler: logRequests(mux),
+ ReadHeaderTimeout: 5 * time.Second,
+ }
+ log.Printf("api-pedidos escuchando en http://%s (issuer %s, audiencia %s)", listenHost(addr), issuer, audience)
+ log.Fatal(srv.ListenAndServe())
+}
+
+// statusRecorder recuerda el código de estado para el log.
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+}
+
+func (s *statusRecorder) WriteHeader(code int) {
+ s.status = code
+ s.ResponseWriter.WriteHeader(code)
+}
+
+// logRequests escribe una línea por petición, con su código de estado.
+func logRequests(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rec, r)
+ log.Printf("%s %s → %d (%s)", r.Method, r.URL.Path, rec.status, time.Since(start).Round(time.Millisecond))
+ })
+}
+
+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
+}
cmd/token/main.go
@@ -0,0 +1,82 @@
+// Command token consigue un access token del realm tienda con el flujo de
+// dispositivo (Device Authorization Grant) del client público tienda-cli.
+// Sirve para probar api-pedidos desde la terminal:
+//
+// TOKEN=$(go run ./cmd/token)
+// curl -H "Authorization: Bearer $TOKEN" localhost:8081/pedidos
+//
+// Las instrucciones van a stderr; por stdout solo sale el token.
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "golang.org/x/oauth2"
+)
+
+func main() {
+ issuer := flag.String("issuer", "http://localhost:8080/realms/tienda", "URL del realm")
+ clientID := flag.String("client", "tienda-cli", "client público con el Device Authorization Grant activado")
+ scopes := flag.String("scope", "", "scopes extra, separados por espacios (p. ej. \"pedidos:escribir\")")
+ showClaims := flag.Bool("claims", false, "muestra en stderr los claims del access token")
+ flag.Parse()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+
+ provider, err := oidc.NewProvider(ctx, *issuer)
+ if err != nil {
+ log.Fatal(err)
+ }
+ cfg := oauth2.Config{
+ ClientID: *clientID,
+ Endpoint: provider.Endpoint(), // incluye device_authorization_endpoint
+ Scopes: append([]string{oidc.ScopeOpenID}, strings.Fields(*scopes)...),
+ }
+
+ // 1. Pedimos un código de dispositivo y otro para el usuario.
+ da, err := cfg.DeviceAuth(ctx)
+ if err != nil {
+ log.Fatalf("device auth: %v", err)
+ }
+ fmt.Fprintf(os.Stderr, "Abre en el navegador:\n\n %s\n\ne introduce el código %s, o abre directamente:\n\n %s\n\nEsperando…\n",
+ da.VerificationURI, da.UserCode, da.VerificationURIComplete)
+
+ // 2. Mientras el usuario inicia sesión, x/oauth2 consulta el endpoint de
+ // token cada «interval» segundos (y respeta authorization_pending y slow_down).
+ tok, err := cfg.DeviceAccessToken(ctx, da)
+ if err != nil {
+ log.Fatalf("device token: %v", err)
+ }
+
+ if *showClaims {
+ printClaims(tok.AccessToken)
+ }
+ fmt.Println(tok.AccessToken)
+}
+
+// printClaims muestra el payload del JWT, sin verificarlo: es solo para mirar.
+func printClaims(jwt string) {
+ parts := strings.Split(jwt, ".")
+ if len(parts) != 3 {
+ return
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return
+ }
+ var v map[string]any
+ if json.Unmarshal(raw, &v) == nil {
+ pretty, _ := json.MarshalIndent(v, "", " ")
+ fmt.Fprintf(os.Stderr, "%s\n", pretty)
+ }
+}
cmd/web/main.go
@@ -15,6 +15,7 @@
"time"
"tienda/internal/auth"
+ "tienda/internal/pedidosclient"
"tienda/internal/session"
"tienda/internal/web"
)
@@ -29,6 +30,7 @@
PostLogoutRedirectURL: env("OIDC_POST_LOGOUT_URL", "http://localhost:3000/"),
}
addr := env("ADDR", ":3000")
+ apiURL := env("API_URL", "http://localhost:8081") // api-pedidos
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -41,7 +43,7 @@
mux := http.NewServeMux()
a.Register(mux)
- web.Register(mux, a)
+ web.Register(mux, a, pedidosclient.New(apiURL))
srv := &http.Server{
Addr: addr,
infra/realm/tienda-realm.json
@@ -30,9 +30,15 @@
"description": "${role_default-roles}",
"composite": true,
"composites": {
- "realm": ["offline_access", "uma_authorization"],
+ "realm": [
+ "offline_access",
+ "uma_authorization"
+ ],
"client": {
- "account": ["view-profile", "manage-account"]
+ "account": [
+ "view-profile",
+ "manage-account"
+ ]
}
}
}
@@ -45,6 +51,7 @@
},
"users": [
{
+ "id": "00000000-0000-4000-8000-0000000000a1",
"username": "ana",
"enabled": true,
"email": "ana@tienda.test",
@@ -52,11 +59,19 @@
"firstName": "Ana",
"lastName": "Cliente",
"credentials": [
- { "type": "password", "value": "ana123", "temporary": false }
+ {
+ "type": "password",
+ "value": "ana123",
+ "temporary": false
+ }
],
- "realmRoles": ["default-roles-tienda", "cliente"]
- },
- {
+ "realmRoles": [
+ "default-roles-tienda",
+ "cliente"
+ ]
+ },
+ {
+ "id": "00000000-0000-4000-8000-0000000000c1",
"username": "carlos",
"enabled": true,
"email": "carlos@tienda.test",
@@ -64,9 +79,17 @@
"firstName": "Carlos",
"lastName": "Admin",
"credentials": [
- { "type": "password", "value": "carlos123", "temporary": false }
+ {
+ "type": "password",
+ "value": "carlos123",
+ "temporary": false
+ }
],
- "realmRoles": ["default-roles-tienda", "cliente", "admin"]
+ "realmRoles": [
+ "default-roles-tienda",
+ "cliente",
+ "admin"
+ ]
}
],
"clients": [
@@ -85,12 +108,779 @@
"serviceAccountsEnabled": false,
"rootUrl": "http://localhost:3000",
"baseUrl": "/",
- "redirectUris": ["http://localhost:3000/callback"],
- "webOrigins": ["http://localhost:3000"],
+ "redirectUris": [
+ "http://localhost:3000/callback"
+ ],
+ "webOrigins": [
+ "http://localhost:3000"
+ ],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:3000/"
+ },
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "basic",
+ "email",
+ "api-pedidos"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "organization",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "clientId": "tienda-cli",
+ "name": "Tienda CLI",
+ "description": "Herramienta de línea de comandos (Device Authorization Grant)",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": true,
+ "standardFlowEnabled": false,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "attributes": {
+ "oauth2.device.authorization.grant.enabled": "true"
+ },
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "basic",
+ "email",
+ "api-pedidos"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "organization",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ },
+ {
+ "clientId": "api-pedidos",
+ "name": "API de pedidos",
+ "description": "Resource server: recibe access tokens, no los pide",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": false,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "api-pedidos-secret",
+ "standardFlowEnabled": false,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": false,
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "basic",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "organization",
+ "offline_access",
+ "microprofile-jwt"
+ ]
+ }
+ ],
+ "clientScopes": [
+ {
+ "name": "api-pedidos",
+ "description": "Añade api-pedidos a la audiencia (aud) del access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "audiencia api-pedidos",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "api-pedidos",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "introspection.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "email",
+ "description": "OpenID Connect built-in scope: email",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "consent.screen.text": "${emailScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "email verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "emailVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email_verified",
+ "jsonType.label": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "name": "offline_access",
+ "description": "OpenID Connect built-in scope: offline_access",
+ "protocol": "openid-connect",
+ "attributes": {
+ "consent.screen.text": "${offlineAccessScopeConsentText}",
+ "display.on.consent.screen": "true"
}
+ },
+ {
+ "name": "web-origins",
+ "description": "OpenID Connect scope for add allowed web origins to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "allowed web origins",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-allowed-origins-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "AuthnContextClassRef",
+ "description": "AuthnContextClassRef Level of Authentiation",
+ "protocol": "saml",
+ "attributes": {},
+ "protocolMappers": [
+ {
+ "name": "AuthnContextClassRef",
+ "protocol": "saml",
+ "protocolMapper": "saml-authn-context-class-ref-mapper",
+ "consentRequired": false,
+ "config": {}
+ }
+ ]
+ },
+ {
+ "name": "service_account",
+ "description": "Specific scope for a client enabled for service accounts",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "Client Host",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "clientHost",
+ "id.token.claim": "true",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "clientHost",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "Client ID",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "client_id",
+ "id.token.claim": "true",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "client_id",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "Client IP Address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "clientAddress",
+ "id.token.claim": "true",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "clientAddress",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "address",
+ "description": "OpenID Connect built-in scope: address",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "consent.screen.text": "${addressScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-address-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute.formatted": "formatted",
+ "user.attribute.country": "country",
+ "introspection.token.claim": "true",
+ "user.attribute.postal_code": "postal_code",
+ "userinfo.token.claim": "true",
+ "user.attribute.street": "street",
+ "id.token.claim": "true",
+ "user.attribute.region": "region",
+ "access.token.claim": "true",
+ "user.attribute.locality": "locality"
+ }
+ }
+ ]
+ },
+ {
+ "name": "phone",
+ "description": "OpenID Connect built-in scope: phone",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "consent.screen.text": "${phoneScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "phone number verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumberVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number_verified",
+ "jsonType.label": "boolean"
+ }
+ },
+ {
+ "name": "phone number",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumber",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "basic",
+ "description": "OpenID Connect scope for add all basic claims to the token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "sub",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-sub-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ },
+ {
+ "name": "auth_time",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usersessionmodel-note-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.session.note": "AUTH_TIME",
+ "id.token.claim": "true",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "auth_time",
+ "jsonType.label": "long"
+ }
+ }
+ ]
+ },
+ {
+ "name": "organization",
+ "description": "Additional claims about the organization a subject belongs to",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "consent.screen.text": "${organizationScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "organization",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-organization-membership-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "organization",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "role_list",
+ "description": "SAML role list",
+ "protocol": "saml",
+ "attributes": {
+ "consent.screen.text": "${samlRoleListScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "role list",
+ "protocol": "saml",
+ "protocolMapper": "saml-role-list-mapper",
+ "consentRequired": false,
+ "config": {
+ "single": "false",
+ "attribute.nameformat": "Basic",
+ "attribute.name": "Role"
+ }
+ }
+ ]
+ },
+ {
+ "name": "acr",
+ "description": "OpenID Connect scope for add acr (authentication context class reference) to the token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "acr loa level",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-acr-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "profile",
+ "description": "OpenID Connect built-in scope: profile",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "consent.screen.text": "${profileScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "birthdate",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "birthdate",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "birthdate",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "updated at",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "updatedAt",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "updated_at",
+ "jsonType.label": "long"
+ }
+ },
+ {
+ "name": "full name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-full-name-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "name": "picture",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "picture",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "picture",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "middle name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "middleName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "middle_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "zoneinfo",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "zoneinfo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "zoneinfo",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "profile",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "profile",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "profile",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "family name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "lastName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "family_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "given name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "firstName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "given_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "locale",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "locale",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "locale",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "nickname",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "nickname",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "nickname",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "website",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "website",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "website",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "gender",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "gender",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "gender",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "microprofile-jwt",
+ "description": "Microprofile - JWT built-in scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "upn",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "upn",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "multivalued": "true",
+ "user.attribute": "foo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "groups",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "saml_organization",
+ "description": "Organization Membership",
+ "protocol": "saml",
+ "attributes": {
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "organization",
+ "protocol": "saml",
+ "protocolMapper": "saml-organization-membership-mapper",
+ "consentRequired": false,
+ "config": {}
+ }
+ ]
+ },
+ {
+ "name": "roles",
+ "description": "OpenID Connect scope for add user roles to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "consent.screen.text": "${rolesScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "client roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "resource_access.${client_id}.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "name": "audience resolve",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-resolve-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ },
+ {
+ "name": "realm roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "introspection.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ }
+ ]
}
+ ],
+ "defaultDefaultClientScopes": [
+ "role_list",
+ "saml_organization",
+ "AuthnContextClassRef",
+ "profile",
+ "email",
+ "roles",
+ "web-origins",
+ "acr",
+ "basic"
+ ],
+ "defaultOptionalClientScopes": [
+ "offline_access",
+ "address",
+ "phone",
+ "microprofile-jwt",
+ "organization"
]
}
internal/api/api.go
@@ -0,0 +1,45 @@
+// Package api contiene los handlers HTTP de api-pedidos.
+package api
+
+import (
+ "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. Todas exigen un access token válido.
+func Register(mux *http.ServeMux, v *apiauth.Verifier, store *pedidos.Store) {
+ h := &handlers{store: store}
+ mux.Handle("GET /pedidos", v.Middleware(http.HandlerFunc(h.list)))
+ mux.Handle("GET /pedidos/{id}", v.Middleware(http.HandlerFunc(h.get)))
+}
+
+// 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. Si es de otro usuario
+// respondemos 404, no 403: así no revelamos qué números de pedido existen.
+func (h *handlers) get(w http.ResponseWriter, r *http.Request) {
+ p := apiauth.FromContext(r.Context())
+ 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
+ }
+ o, ok := h.store.Get(id)
+ if !ok || o.Owner != p.Subject {
+ jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
+ return
+ }
+ jsonhttp.Write(w, http.StatusOK, o)
+}
internal/apiauth/apiauth.go
@@ -0,0 +1,131 @@
+// Package apiauth protege una API con access tokens de Keycloak: valida el
+// JWT localmente (firma con el JWKS del realm, iss, aud, exp) y deja en el
+// contexto a quién representa el token.
+package apiauth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+
+ "tienda/internal/jsonhttp"
+)
+
+// Principal es quien hace la petición, según el access token verificado.
+type Principal struct {
+ Subject string // sub: el usuario (o la service account de un client)
+ Username string // preferred_username
+ ClientID string // azp: la aplicación que pidió el token
+ Scopes []string // scope, separado por espacios
+ Roles []string // realm_access.roles
+}
+
+// Verifier valida access tokens emitidos por un realm para una audiencia.
+type Verifier struct {
+ verifier *oidc.IDTokenVerifier
+}
+
+// NewVerifier lee el descubrimiento del issuer y prepara la validación.
+// audience es el client ID de la API: el token debe incluirlo en «aud».
+func NewVerifier(ctx context.Context, issuer, audience string) (*Verifier, error) {
+ provider, err := oidc.NewProvider(ctx, issuer)
+ if err != nil {
+ return nil, fmt.Errorf("descubrimiento OIDC en %s: %w", issuer, err)
+ }
+ // go-oidc se diseñó para ID tokens, pero las comprobaciones son las mismas
+ // que necesita un access token JWT: firma (JWKS, con caché y rotación de
+ // claves), iss exacto, aud contiene ClientID y exp.
+ return &Verifier{verifier: provider.Verifier(&oidc.Config{ClientID: audience})}, nil
+}
+
+// Verify comprueba un access token y devuelve a quién representa.
+func (v *Verifier) Verify(ctx context.Context, raw string) (*Principal, error) {
+ tok, err := v.verifier.Verify(ctx, raw)
+ if err != nil {
+ return nil, err
+ }
+ var c struct {
+ Typ string `json:"typ"`
+ Username string `json:"preferred_username"`
+ AZP string `json:"azp"`
+ Scope string `json:"scope"`
+ RealmAccess struct {
+ Roles []string `json:"roles"`
+ } `json:"realm_access"`
+ }
+ if err := tok.Claims(&c); err != nil {
+ return nil, err
+ }
+ // Keycloak marca cada token con su tipo. Un ID token o un refresh token
+ // nunca deben servir para llamar a la API.
+ if c.Typ != "Bearer" {
+ return nil, fmt.Errorf("no es un access token (typ=%q)", c.Typ)
+ }
+ return &Principal{
+ Subject: tok.Subject,
+ Username: c.Username,
+ ClientID: c.AZP,
+ Scopes: strings.Fields(c.Scope),
+ Roles: c.RealmAccess.Roles,
+ }, nil
+}
+
+type principalKey struct{}
+
+// Middleware exige un access token válido en «Authorization: Bearer …» y
+// guarda el Principal en el contexto de la petición.
+func (v *Verifier) Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, ok := bearerToken(r)
+ if !ok {
+ unauthorized(w, "", "falta la cabecera Authorization: Bearer <access token>")
+ return
+ }
+ p, err := v.Verify(r.Context(), raw)
+ if err != nil {
+ log.Printf("token rechazado: %v", err)
+ var expired *oidc.TokenExpiredError
+ if errors.As(err, &expired) {
+ unauthorized(w, "invalid_token", "el access token ha caducado")
+ return
+ }
+ unauthorized(w, "invalid_token", "access token inválido")
+ return
+ }
+ ctx := context.WithValue(r.Context(), principalKey{}, p)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// FromContext devuelve el Principal que dejó Middleware.
+func FromContext(ctx context.Context) *Principal {
+ p, _ := ctx.Value(principalKey{}).(*Principal)
+ return p
+}
+
+// bearerToken extrae el token de «Authorization: Bearer <token>».
+func bearerToken(r *http.Request) (string, bool) {
+ scheme, tok, ok := strings.Cut(r.Header.Get("Authorization"), " ")
+ if !ok || !strings.EqualFold(scheme, "Bearer") || tok == "" {
+ return "", false
+ }
+ return tok, true
+}
+
+// unauthorized responde 401 con la cabecera WWW-Authenticate de RFC 6750.
+// Sin token no se indica código de error; con un token malo, invalid_token.
+func unauthorized(w http.ResponseWriter, code, description string) {
+ h := `Bearer realm="api-pedidos"`
+ if code != "" {
+ h += fmt.Sprintf(`, error=%q, error_description=%q`, code, description)
+ } else {
+ code = "unauthorized"
+ }
+ w.Header().Set("WWW-Authenticate", h)
+ jsonhttp.Error(w, http.StatusUnauthorized, code, description)
+}
internal/jsonhttp/jsonhttp.go
@@ -0,0 +1,20 @@
+// Package jsonhttp tiene dos ayudas para responder JSON desde una API.
+package jsonhttp
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// Write responde con v codificado en JSON.
+func Write(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+// Error responde con un error en el formato de OAuth 2.0:
+// {"error": "...", "error_description": "..."}.
+func Error(w http.ResponseWriter, status int, code, description string) {
+ Write(w, status, map[string]string{"error": code, "error_description": description})
+}
internal/pedidos/pedidos.go
@@ -0,0 +1,68 @@
+// Package pedidos es el dominio de api-pedidos: los pedidos y un almacén en
+// memoria con datos de ejemplo.
+package pedidos
+
+import (
+ "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"
+)
+
+// 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"`
+}
+
+// Store guarda los pedidos en memoria, seguro para uso concurrente.
+type Store struct {
+ mu sync.Mutex
+ orders map[int]Order
+}
+
+// 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)}
+ 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 {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := []Order{}
+ for _, o := range s.orders {
+ if o.Owner == sub {
+ out = append(out, o)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out
+}
+
+// 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
+}
internal/pedidosclient/client.go
@@ -0,0 +1,77 @@
+// Package pedidosclient es el cliente HTTP que usa tienda-web para llamar a
+// api-pedidos en nombre del usuario, con su access token.
+package pedidosclient
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "golang.org/x/oauth2"
+)
+
+// Order es un pedido tal como lo devuelve api-pedidos.
+type Order struct {
+ ID int `json:"id"`
+ Owner string `json:"owner"`
+ Items string `json:"items"`
+ Total float64 `json:"total"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ErrUnauthorized indica que la API rechazó el access token (401).
+var ErrUnauthorized = errors.New("api-pedidos rechazó el access token")
+
+// Client llama a api-pedidos.
+type Client struct {
+ baseURL string
+ http *http.Client
+}
+
+// New crea un cliente para la API en baseURL (p. ej. http://localhost:8081).
+func New(baseURL string) *Client {
+ return &Client{baseURL: baseURL, http: &http.Client{Timeout: 5 * time.Second}}
+}
+
+// MyOrders devuelve los pedidos del usuario dueño del token.
+func (c *Client) MyOrders(ctx context.Context, tok *oauth2.Token) ([]Order, error) {
+ var out struct {
+ Pedidos []Order `json:"pedidos"`
+ }
+ err := c.do(ctx, tok, http.MethodGet, "/pedidos", &out)
+ return out.Pedidos, err
+}
+
+// do hace la petición con «Authorization: Bearer <access token>» y decodifica
+// la respuesta JSON en out.
+func (c *Client) do(ctx context.Context, tok *oauth2.Token, method, path string, out any) error {
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
+ if err != nil {
+ return err
+ }
+ tok.SetAuthHeader(req) // añade la cabecera Authorization: Bearer …
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return fmt.Errorf("%w: %s", ErrUnauthorized, resp.Header.Get("WWW-Authenticate"))
+ }
+ if resp.StatusCode >= 400 {
+ var e struct {
+ Code string `json:"error"`
+ Description string `json:"error_description"`
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ _ = json.Unmarshal(body, &e)
+ return fmt.Errorf("api-pedidos respondió %s: %s", resp.Status, e.Description)
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
internal/web/templates/pedidos.html
@@ -1,7 +1,9 @@
{{define "content"}}
<h1>Mis pedidos</h1>
-<p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}})</p>
-{{if .Orders}}
+<p class="who">Pedidos de <strong>{{.Session.User.Username}}</strong> ({{.Session.User.Email}}), servidos por <code>api-pedidos</code></p>
+{{if .Error}}
+<div class="note">⚠ {{.Error}}</div>
+{{else if .Orders}}
<table>
<thead><tr><th>Nº</th><th>Artículos</th><th>Total</th><th>Estado</th></tr></thead>
<tbody>
internal/web/web.go
@@ -12,6 +12,7 @@
"time"
"tienda/internal/auth"
+ "tienda/internal/pedidosclient"
"tienda/internal/session"
)
@@ -25,30 +26,11 @@
Price float64
}
-// Order es un pedido de ejemplo. En el módulo 3 vendrán de api-pedidos.
-type Order struct {
- ID int
- Items string
- Total float64
- Status string
-}
-
var catalog = []Product{
{"Gopher de peluche", "El compañero ideal para depurar.", 19.90},
{"Taza «go fmt»", "Formatea tu café automáticamente.", 9.50},
{"Camiseta Keycloak", "Algodón 100 %, talla única de realm.", 15.00},
{"Pegatinas OIDC", "Pack de 10: iss, sub, aud, exp…", 4.99},
-}
-
-// ordersByUser simula una base de datos de pedidos, indexada por username.
-var ordersByUser = map[string][]Order{
- "ana": {
- {1001, "Gopher de peluche ×1, Pegatinas OIDC ×2", 29.88, "Enviado"},
- {1002, "Taza «go fmt» ×1", 9.50, "Pendiente"},
- },
- "carlos": {
- {1003, "Camiseta Keycloak ×2", 30.00, "Entregado"},
- },
}
// claim es una fila de la tabla de /perfil.
@@ -69,7 +51,7 @@
Active string // pestaña activa del menú
Session *session.Session
Products []Product
- Orders []Order
+ Orders []pedidosclient.Order
Claims []claim
UserInfo []claim
Token tokenInfo
@@ -78,12 +60,13 @@
type handlers struct {
auth *auth.Auth
+ api *pedidosclient.Client
pages map[string]*template.Template
}
// Register añade las páginas al mux. /pedidos y /perfil exigen sesión.
-func Register(mux *http.ServeMux, a *auth.Auth) {
- h := &handlers{auth: a, pages: make(map[string]*template.Template)}
+func Register(mux *http.ServeMux, a *auth.Auth, api *pedidosclient.Client) {
+ h := &handlers{auth: a, api: api, pages: make(map[string]*template.Template)}
for _, name := range []string{"home.html", "pedidos.html", "perfil.html"} {
h.pages[name] = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/"+name))
}
@@ -99,13 +82,24 @@
h.render(w, "home.html", pageData{Active: "catalogo", Session: sess, Products: catalog})
}
+// pedidos pide a api-pedidos los pedidos del usuario, con su access token.
func (h *handlers) pedidos(w http.ResponseWriter, r *http.Request) {
sess, _ := h.auth.CurrentSession(r)
- h.render(w, "pedidos.html", pageData{
- Active: "pedidos",
- Session: sess,
- Orders: ordersByUser[sess.User.Username],
- })
+ tok, err := h.auth.Token(r.Context(), sess, false) // renueva si hace falta
+ if errors.Is(err, auth.ErrSessionEnded) {
+ http.Redirect(w, r, auth.LoginURL(r), http.StatusFound)
+ return
+ }
+ data := pageData{Active: "pedidos", Session: sess}
+ if err != nil {
+ data.Error = err.Error()
+ } else if orders, err := h.api.MyOrders(r.Context(), tok); err != nil {
+ log.Printf("api-pedidos: %v", err)
+ data.Error = "No se pudieron cargar los pedidos: " + err.Error()
+ } else {
+ data.Orders = orders
+ }
+ h.render(w, "pedidos.html", data)
}
// perfil muestra los claims del ID token, el estado de los tokens y los datos