<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Autorización el josiete.com</title><link>https://www.josiete.com/tags/autorizaci%C3%B3n/</link><description>Contenido reciente en Autorización el josiete.com</description><generator>Hugo</generator><language>es-es</language><lastBuildDate>Tue, 29 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://www.josiete.com/tags/autorizaci%C3%B3n/index.xml" rel="self" type="application/rss+xml"/><item><title>Autenticación vs. autorización: OAuth 2.0, OpenID Connect y SAML explicados con ejemplos</title><link>https://www.josiete.com/posts/autenticacion-autorizacion-oauth-oidc-saml/</link><pubDate>Tue, 29 Sep 2026 00:00:00 +0000</pubDate><guid>https://www.josiete.com/posts/autenticacion-autorizacion-oauth-oidc-saml/</guid><description>&lt;p&gt;&lt;img src="https://www.josiete.com/images/autenticacion-autorizacion-oauth-oidc-saml.webp" alt="Ilustración de una identidad que recibe acceso a una galería, con permiso de edición sobre una fotografía propia y una fotografía ajena protegida"&gt;&lt;/p&gt;&#10;&lt;p&gt;Una aplicación de fotografías parece un buen lugar para separar conceptos que a menudo se mezclan. Imaginemos que Ana inicia sesión, consulta una galería y modifica el título de una foto que subió ella. Puede ver otra foto de Bruno, pero no cambiarla. ¿Qué ha demostrado cada paso?&lt;/p&gt;&#10;&lt;p&gt;Al iniciar sesión, el sistema ha establecido la identidad de Ana. Al decidir si puede editar cada foto, ha aplicado reglas sobre una acción y un recurso concretos. OAuth 2.0, OpenID Connect (OIDC) y SAML resuelven partes distintas de ese problema. Entender sus límites evita dos errores frecuentes: tratar un token como una autorización ilimitada y usar un dato de identidad como si fuera una credencial para cualquier API.&lt;/p&gt;&#10;&lt;h2 id="autenticación-y-autorización-no-son-sinónimos"&gt;Autenticación y autorización no son sinónimos&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;Autenticación&lt;/strong&gt; responde a «¿quién eres?». Una contraseña, una llave de seguridad, una sesión corporativa o una aplicación de autenticación pueden servir para que el proveedor concluya que quien está delante es Ana.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Autorización&lt;/strong&gt; responde a «¿qué puedes hacer, sobre qué recurso y en qué condiciones?». Puede conceder a Ana &lt;code&gt;photos:read&lt;/code&gt; y &lt;code&gt;photos:write&lt;/code&gt;, pero la API todavía debe decidir si la fotografía &lt;code&gt;p-42&lt;/code&gt; es suya, si está archivada o si una regla de equipo le da acceso. Estar autenticada no convierte a Ana en propietaria de todas las fotos.&lt;/p&gt;&#10;&lt;p&gt;La distinción también aparece en HTTP:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;401 Unauthorized&lt;/strong&gt; significa, pese a su nombre histórico, que faltan credenciales válidas o no se han podido validar. En un esquema Bearer suele acompañarse de &lt;code&gt;WWW-Authenticate&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;403 Forbidden&lt;/strong&gt; significa que se entendió la petición y las credenciales eran válidas, pero la operación está prohibida por la lógica de autorización. Algunas APIs eligen devolver 404 para no revelar que el recurso existe.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;La convención exacta de errores debe documentarse, pero usar 401 para un access token expirado o con audiencia incorrecta y 403 para un scope, rol o propiedad insuficiente da a clientes y operadores una señal útil. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/401"&gt;HTTP Semantics y la documentación de MDN distinguen ambos casos&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="oauth-20-acceso-delegado-no-un-sistema-de-permisos-completo"&gt;OAuth 2.0: acceso delegado, no un sistema de permisos completo&lt;/h2&gt;&#10;&lt;p&gt;OAuth 2.0 permite que una aplicación obtenga acceso limitado a un recurso sin conocer la contraseña de la persona. En nuestro caso, la aplicación web de la galería no recibe la contraseña de Ana: la introduce, si es necesario, en el proveedor de identidad. La aplicación recibe un token que puede presentar ante la API de fotografías.&lt;/p&gt;&#10;&lt;p&gt;Sus cuatro papeles explican el recorrido:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;el &lt;strong&gt;resource owner&lt;/strong&gt; es Ana, titular de las fotos o de la decisión de acceso;&lt;/li&gt;&#10;&lt;li&gt;el &lt;strong&gt;client&lt;/strong&gt; es la aplicación web que quiere llamar a la API;&lt;/li&gt;&#10;&lt;li&gt;el &lt;strong&gt;authorization server&lt;/strong&gt; autentica a Ana, recoge consentimiento cuando procede y emite credenciales;&lt;/li&gt;&#10;&lt;li&gt;el &lt;strong&gt;resource server&lt;/strong&gt; es la API que protege las fotos y valida la credencial recibida.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;El consentimiento, los &lt;em&gt;scopes&lt;/em&gt; y la autorización de la API son capas relacionadas, pero distintas. Un consentimiento puede permitir al cliente pedir &lt;code&gt;photos:read&lt;/code&gt;; un scope limita el tipo de acceso delegado solicitado o concedido; y la API decide en cada petición si el token, la acción y el recurso satisfacen su política. En particular, &lt;code&gt;photos:write&lt;/code&gt; no significa «editar cualquier fotografía»: la API debe comparar el sujeto del token con el propietario de &lt;code&gt;p-42&lt;/code&gt;, o consultar una política que admita, por ejemplo, a un editor del equipo.&lt;/p&gt;&#10;&lt;p&gt;OAuth especifica cómo se obtiene y se usa un token de acceso, no el modelo universal de permisos de todas las aplicaciones. Un token puede ser opaco, un JWT, contener scopes, roles o atributos definidos por el proveedor. No diseñes una política de negocio suponiendo que todos los proveedores representarán esos datos de la misma forma. La &lt;a href="https://datatracker.ietf.org/doc/html/rfc6749"&gt;especificación base de OAuth 2.0&lt;/a&gt; y su &lt;a href="https://datatracker.ietf.org/doc/html/rfc9700"&gt;BCP de seguridad&lt;/a&gt; son el punto de partida.&lt;/p&gt;&#10;&lt;h2 id="openid-connect-añade-la-pregunta-quién-inició-sesión"&gt;OpenID Connect añade la pregunta «¿quién inició sesión?»&lt;/h2&gt;&#10;&lt;p&gt;OAuth por sí solo resuelve autorización delegada: una aplicación puede tener permiso para invocar un recurso sin que eso sea una afirmación de identidad suficiente para la propia aplicación. OIDC añade una capa de autenticación sobre OAuth 2.0 y normaliza descubrimiento, &lt;em&gt;claims&lt;/em&gt; e &lt;strong&gt;ID token&lt;/strong&gt;. No es el protocolo histórico OpenID 2.0: son tecnologías distintas con nombres parecidos; hoy, cuando un proveedor ofrece «Sign in with…» sobre OAuth, normalmente habla de OpenID Connect.&lt;/p&gt;&#10;&lt;p&gt;Después de un flujo OIDC se pueden recibir tres cosas, cada una con un destinatario y una finalidad:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Artefacto&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Destinatario principal&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Para qué sirve&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;ID token&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;El cliente&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Afirma que el proveedor autenticó a un usuario y comunica &lt;em&gt;claims&lt;/em&gt; como &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt; y, si se pidieron, perfil o correo.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;Access token&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;La API o resource server&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Se presenta en &lt;code&gt;Authorization: Bearer …&lt;/code&gt; para acceder a recursos protegidos.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;Refresh token&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;El cliente autorizado, si el proveedor lo concede&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Permite pedir nuevos access tokens sin repetir el login en ciertas condiciones.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Un ID token tiene como audiencia al cliente que inició sesión; no lo envíes como credencial a la API. Aunque ambos artefactos puedan ser JWT visualmente parecidos, cambian el emisor esperado, la audiencia, las &lt;em&gt;claims&lt;/em&gt; y el contrato de seguridad. La &lt;a href="https://openid.net/specs/openid-connect-core-1_0-18.html"&gt;especificación OIDC Core&lt;/a&gt; exige, entre otras validaciones, comprobar emisor, audiencia, firma y expiración del ID token.&lt;/p&gt;&#10;&lt;h2 id="un-inicio-de-sesión-moderno-authorization-code-con-pkce"&gt;Un inicio de sesión moderno: Authorization Code con PKCE&lt;/h2&gt;&#10;&lt;p&gt;Para una aplicación nueva en navegador, el flujo recomendado es &lt;strong&gt;Authorization Code con PKCE&lt;/strong&gt;. El navegador recibe un código de corta vida a través de la redirección y el cliente lo canjea en el token endpoint. PKCE vincula ese canje a la instancia que inició el flujo mediante un secreto efímero llamado &lt;code&gt;code_verifier&lt;/code&gt;; solo se envía al authorization server su desafío derivado, normalmente con &lt;code&gt;S256&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Este diagrama usa texto preformateado, por lo que no depende de JavaScript ni de un renderizador especial del sitio:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;Navegador / SPA Authorization server API de fotos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; | | |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |-- /authorize ----------&amp;gt;| |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; | response_type=code, state, nonce, |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; | code_challenge=S256(verifier), scope |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |&amp;lt;-- login y consentimiento ----------------------------------|&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |&amp;lt;-- redirect_uri?code=...&amp;amp;state=... --| |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; | | |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |-- /token: code + code_verifier ----&amp;gt;| |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |&amp;lt;-- access_token, id_token (y quizá refresh_token) ---------|&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; | |&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |-- GET /photos/p-42, Authorization: Bearer access_token ---&amp;gt;|&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; |&amp;lt;-- 200, 403 o 401 ------------------------------------------|&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Los tres valores aleatorios no son equivalentes:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;state&lt;/code&gt;&lt;/strong&gt; vincula la respuesta de autorización con la transacción y la sesión del navegador que la inició; mitiga CSRF y puede transportar estado de navegación protegido contra manipulación.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;nonce&lt;/code&gt;&lt;/strong&gt; vincula una petición OIDC con el ID token que vuelve y ayuda a detectar repetición de esa afirmación de autenticación. El cliente debe comprobarlo cuando lo usa.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;PKCE&lt;/strong&gt; vincula el código al &lt;code&gt;code_verifier&lt;/code&gt; secreto de la instancia cliente. Evita que quien robe el código pueda canjearlo sin ese valor.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;La &lt;a href="https://datatracker.ietf.org/doc/html/rfc9700"&gt;BCP de OAuth&lt;/a&gt; exige PKCE para clientes públicos y recomienda &lt;code&gt;S256&lt;/code&gt;; la guía actual para aplicaciones de navegador también favorece Authorization Code con PKCE. No presentamos Implicit ni el intercambio directo de contraseñas como opciones recomendadas para una aplicación nueva: el primero expone tokens en la navegación y el segundo hace que el cliente maneje credenciales que debería manejar el proveedor.&lt;/p&gt;&#10;&lt;h2 id="saml-federación-y-sso-empresarial"&gt;SAML: federación y SSO empresarial&lt;/h2&gt;&#10;&lt;p&gt;SAML 2.0 es muy común en federación empresarial. Una empresa puede tener un &lt;strong&gt;Identity Provider (IdP)&lt;/strong&gt; corporativo —por ejemplo, el directorio de empleados con MFA— y varias aplicaciones que actúan como &lt;strong&gt;Service Providers (SP)&lt;/strong&gt;: nóminas, intranet, soporte y la galería interna. Cuando Ana entra en la galería, el SP redirige al IdP; como Ana ya tiene una sesión SSO en el IdP, este entrega al SP una &lt;strong&gt;assertion&lt;/strong&gt; firmada que afirma su identidad y, según la configuración, atributos o grupos.&lt;/p&gt;&#10;&lt;p&gt;Así, Ana no introduce su contraseña en cada aplicación. El SP valida emisor, destinatario, firma, audiencia, condiciones temporales y la correlación de la respuesta antes de crear &lt;strong&gt;su propia sesión de aplicación&lt;/strong&gt;. Las siguientes peticiones de Ana pueden llevar la cookie de esa sesión: no hay que repetir un intercambio SAML en cada carga de página.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;SAML 2.0&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;OpenID Connect&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Formato y entorno habitual&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Assertions XML; muy extendido en SSO de aplicaciones empresariales web.&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;JSON/JWT y endpoints OAuth; cómodo para web, móvil y APIs.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Papel típico&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Federación IdP–SP y sesión de la aplicación.&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Autenticación del cliente y autorización delegada hacia APIs.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Decisión práctica&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Encaja si la organización o el SP ya usa SAML.&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Encaja si se integran clientes modernos y APIs OAuth.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;No es una carrera para «reemplazar» universalmente uno por otro. Las integraciones existentes, los productos SaaS, el modelo de sesión y los requisitos de API determinan la elección. &lt;a href="https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf"&gt;SAML 2.0 Core&lt;/a&gt; define las assertions; OIDC cubre una familia distinta de intercambios y perfiles.&lt;/p&gt;&#10;&lt;h2 id="hay-que-preguntar-constantemente-quién-eres"&gt;¿Hay que preguntar constantemente quién eres?&lt;/h2&gt;&#10;&lt;p&gt;No necesariamente. Conviene separar el tráfico del &lt;strong&gt;login inicial&lt;/strong&gt; —redirecciones, autenticación, consentimiento y canje de código— de las llamadas normales a la API. Estas últimas no deberían convertir al authorization server en una dependencia síncrona innecesaria, aunque cada diseño tiene su equilibrio.&lt;/p&gt;&#10;&lt;p&gt;Un access token puede ser un &lt;strong&gt;JWT firmado&lt;/strong&gt;. La API descarga las claves públicas del emisor desde su JWKS, las guarda en caché y verifica localmente la firma, el algoritmo permitido, &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt; y &lt;code&gt;exp&lt;/code&gt;. Cuando llega un &lt;code&gt;kid&lt;/code&gt; desconocido, una biblioteca correcta vuelve a consultar el JWKS para admitir rotación de claves. Tras calentar la caché, la latencia es local y la API puede seguir validando durante una caída breve del proveedor, pero una revocación no se refleja automáticamente en un JWT ya emitido.&lt;/p&gt;&#10;&lt;p&gt;También puede ser un &lt;strong&gt;token opaco&lt;/strong&gt;: una referencia sin significado para la API. Esta llama al endpoint de &lt;em&gt;introspection&lt;/em&gt; autenticándose como resource server y recibe, por ejemplo, &lt;code&gt;active: true&lt;/code&gt;. La consulta permite saber con rapidez si fue revocado, a costa de latencia, disponibilidad y carga del authorization server. Es posible cachear una respuesta positiva o negativa, pero su vida útil define cuánto se retrasa la revocación.&lt;/p&gt;&#10;&lt;p&gt;Una tercera posibilidad es una sesión propia de la aplicación: cada petición consulta —o usa una caché de— su almacén de sesiones. Facilita invalidación centralizada, pero hace que ese almacén forme parte del camino crítico. No existe un número universal de llamadas a identidad: depende de si hay JWT u opacos, TTL de cachés, gateway, riesgo aceptable, consistencia requerida y topología.&lt;/p&gt;&#10;&lt;p&gt;JWT es un formato de &lt;em&gt;claims&lt;/em&gt;, no OAuth. OAuth puede emitir access tokens JWT u opacos; y un JWT puede usarse fuera de OAuth. Un &lt;a href="https://datatracker.ietf.org/doc/html/rfc7517"&gt;JWKS es simplemente un conjunto de JWKs públicos&lt;/a&gt;; la &lt;a href="https://datatracker.ietf.org/doc/html/rfc7662"&gt;introspección de OAuth&lt;/a&gt; especifica la señal &lt;code&gt;active&lt;/code&gt; para tokens opacos o centralmente gestionados.&lt;/p&gt;&#10;&lt;h2 id="por-qué-expiran-los-tokens"&gt;Por qué expiran los tokens&lt;/h2&gt;&#10;&lt;p&gt;Una expiración corta reduce la ventana de uso de una credencial robada. No evita el robo: un atacante puede usar un token válido hasta que expire. Para no obligar a iniciar sesión cada pocos minutos, el cliente puede renovar mediante un refresh token cuando el proveedor lo conceda. En esa renovación, el servidor puede reevaluar cuenta, cliente, consentimiento y permisos antes de emitir otro access token.&lt;/p&gt;&#10;&lt;p&gt;Un cambio de permisos tampoco invalida mágicamente todos los JWT ya emitidos. Hasta su &lt;code&gt;exp&lt;/code&gt;, una API que solo hace validación local ve las &lt;em&gt;claims&lt;/em&gt; anteriores. Según el riesgo, se combinan expiraciones breves, introspección o listas de revocación para operaciones sensibles, &lt;em&gt;token versioning&lt;/em&gt;, cierre de sesiones y reevaluación de políticas en la API.&lt;/p&gt;&#10;&lt;p&gt;Los refresh tokens merecen más protección que un access token breve. Una práctica habitual es la &lt;strong&gt;rotación&lt;/strong&gt;: al usar uno se entrega otro y se invalida el anterior; reutilizar uno viejo es una señal de posible robo. La &lt;strong&gt;revocación&lt;/strong&gt; retira explícitamente la validez de una credencial o concesión. El &lt;strong&gt;cierre de sesión&lt;/strong&gt; borra la sesión del cliente y puede notificar al proveedor; la &lt;strong&gt;expiración&lt;/strong&gt; sucede al llegar &lt;code&gt;exp&lt;/code&gt;; y la &lt;strong&gt;revocación&lt;/strong&gt; es una decisión del servidor antes de ese momento. Son mecanismos complementarios, no sinónimos.&lt;/p&gt;&#10;&lt;h2 id="ejemplo-local-reproducible-keycloak-javascript-y-fastapi"&gt;Ejemplo local reproducible: Keycloak, JavaScript y FastAPI&lt;/h2&gt;&#10;&lt;p&gt;El ejemplo siguiente es código funcional, no pseudocódigo, si se aplica la configuración indicada. Está deliberadamente pequeño: usa datos en memoria y una política de propiedad sencilla para mostrar las tres comprobaciones que importan: token válido, permiso y recurso. No construye un proveedor OAuth ni implementa criptografía propia.&lt;/p&gt;&#10;&lt;h3 id="1-arrancar-y-configurar-keycloak"&gt;1. Arrancar y configurar Keycloak&lt;/h3&gt;&#10;&lt;p&gt;Se puede ejecutar un proveedor local con una imagen fijada; es una configuración de desarrollo, no una receta de producción:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;docker run --rm --name photos-keycloak -p 8080:8080 &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -e KC_BOOTSTRAP_ADMIN_USERNAME&lt;span style="color:#f92672"&gt;=&lt;/span&gt;admin &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; -e KC_BOOTSTRAP_ADMIN_PASSWORD&lt;span style="color:#f92672"&gt;=&lt;/span&gt;admin &lt;span style="color:#ae81ff"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; quay.io/keycloak/keycloak:26.0.7 start-dev&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;En &lt;code&gt;http://localhost:8080/admin/&lt;/code&gt;, crea el realm &lt;code&gt;photos&lt;/code&gt; y los usuarios &lt;code&gt;alice&lt;/code&gt;, &lt;code&gt;bob&lt;/code&gt; y &lt;code&gt;reader&lt;/code&gt;. Crea el cliente OIDC &lt;strong&gt;público&lt;/strong&gt; &lt;code&gt;photos-spa&lt;/code&gt; con &lt;em&gt;Client authentication&lt;/em&gt; desactivado, &lt;em&gt;Standard flow&lt;/em&gt; activado, PKCE &lt;code&gt;S256&lt;/code&gt;, URI de redirección &lt;code&gt;http://localhost:5173/*&lt;/code&gt; y &lt;em&gt;Web origin&lt;/em&gt; &lt;code&gt;http://localhost:5173&lt;/code&gt;. Crea también el cliente &lt;code&gt;photos-api&lt;/code&gt; y añade al token de &lt;code&gt;photos-spa&lt;/code&gt; un &lt;em&gt;Audience mapper&lt;/em&gt; con audiencia &lt;code&gt;photos-api&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Para el ejercicio, crea los &lt;em&gt;client scopes&lt;/em&gt; opcionales &lt;code&gt;photos:read&lt;/code&gt; y &lt;code&gt;photos:write&lt;/code&gt;, y solicita ambos desde la SPA. Asigna a &lt;code&gt;alice&lt;/code&gt; y &lt;code&gt;bob&lt;/code&gt; el rol de realm &lt;code&gt;photo-editor&lt;/code&gt;; deja a &lt;code&gt;reader&lt;/code&gt; sin él. En un sistema real, que se emita un scope o rol debe responder a una política del authorization server, no a que el navegador escriba un nombre de scope en una URL. El rol adicional deja visible esa distinción en esta demo.&lt;/p&gt;&#10;&lt;p&gt;Keycloak mantiene el adaptador &lt;a href="https://www.keycloak.org/securing-apps/javascript-adapter"&gt;&lt;code&gt;keycloak-js&lt;/code&gt;&lt;/a&gt;: su documentación indica que un cliente del navegador debe ser público, que el adaptador usa Authorization Code por defecto y que conserva tokens en memoria.&lt;/p&gt;&#10;&lt;h3 id="2-cliente-javascript-público"&gt;2. Cliente JavaScript público&lt;/h3&gt;&#10;&lt;p&gt;Instala la biblioteca y sirve el archivo desde &lt;code&gt;http://localhost:5173&lt;/code&gt; con tu herramienta habitual de desarrollo:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;npm install keycloak-js&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// main.js — código funcional para una SPA de demostración&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;import&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Keycloak&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;from&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;keycloak-js&amp;#34;&lt;/span&gt;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;keycloak&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;new&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;Keycloak&lt;/span&gt;({&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;url&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;http://localhost:8080&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;realm&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;photos&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;clientId&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;photos-spa&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;});&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;await&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;keycloak&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;init&lt;/span&gt;({&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;onLoad&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;login-required&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;scope&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;photos:read photos:write&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;pkceMethod&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;S256&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;checkLoginIframe&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;false&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;});&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;async&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;function&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;api&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;path&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;options&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {}) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#75715e"&gt;// Renueva antes de la llamada; si falla, no enviamos una credencial caducada.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;await&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;keycloak&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;updateToken&lt;/span&gt;(&lt;span style="color:#ae81ff"&gt;30&lt;/span&gt;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;fetch&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;`http://localhost:8000&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;path&lt;/span&gt;&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;, {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ...&lt;span style="color:#a6e22e"&gt;options&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;headers&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; ...&lt;span style="color:#a6e22e"&gt;options&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;headers&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Authorization&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;`Bearer &lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;keycloak&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;token&lt;/span&gt;&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;Content-Type&amp;#34;&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;application/json&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; },&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; });&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;export&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;async&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;function&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;readPhoto&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;id&lt;/span&gt;) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;api&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;`/photos/&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;id&lt;/span&gt;&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;);&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;export&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;async&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;function&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;renamePhoto&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;id&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;title&lt;/span&gt;) {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;api&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;`/photos/&lt;/span&gt;&lt;span style="color:#e6db74"&gt;${&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;id&lt;/span&gt;&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;`&lt;/span&gt;, {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;method&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;PATCH&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;body&lt;/span&gt;&lt;span style="color:#f92672"&gt;:&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;JSON&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;stringify&lt;/span&gt;({ &lt;span style="color:#a6e22e"&gt;title&lt;/span&gt; }),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; });&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No hay ni debe haber &lt;code&gt;client_secret&lt;/code&gt; en este código: cualquiera puede leer lo que llega al navegador. Mantener el access token en memoria reduce la persistencia ante un XSS posterior o una copia del perfil, pero &lt;strong&gt;no elimina XSS&lt;/strong&gt; ni protege una página ya comprometida. Al recargar se pierde el estado y habrá que reanudar la sesión con el proveedor. Tampoco registres tokens en consola, analítica, URL o errores.&lt;/p&gt;&#10;&lt;h3 id="3-api-fastapi-que-valida-el-access-token"&gt;3. API FastAPI que valida el access token&lt;/h3&gt;&#10;&lt;p&gt;Instala dependencias con soporte criptográfico de PyJWT:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;python -m pip install &lt;span style="color:#e6db74"&gt;&amp;#34;fastapi[standard]&amp;#34;&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;pyjwt[crypto]&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Guarda esto como &lt;code&gt;app.py&lt;/code&gt; y ejecútalo con &lt;code&gt;fastapi dev app.py&lt;/code&gt;. La audiencia coincide con el &lt;em&gt;Audience mapper&lt;/em&gt; de Keycloak; aceptar solo &lt;code&gt;RS256&lt;/code&gt; evita que el encabezado no confiable del token elija el algoritmo.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# app.py — código funcional de demostración; sustituye PHOTOS por una base de datos&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; uuid &lt;span style="color:#f92672"&gt;import&lt;/span&gt; uuid4&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; jwt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; fastapi &lt;span style="color:#f92672"&gt;import&lt;/span&gt; Depends, FastAPI, HTTPException, status&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; fastapi.middleware.cors &lt;span style="color:#f92672"&gt;import&lt;/span&gt; CORSMiddleware&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; fastapi.security &lt;span style="color:#f92672"&gt;import&lt;/span&gt; HTTPAuthorizationCredentials, HTTPBearer&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; jwt &lt;span style="color:#f92672"&gt;import&lt;/span&gt; PyJWKClient&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; jwt.exceptions &lt;span style="color:#f92672"&gt;import&lt;/span&gt; ExpiredSignatureError, InvalidTokenError, PyJWKClientError&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;from&lt;/span&gt; pydantic &lt;span style="color:#f92672"&gt;import&lt;/span&gt; BaseModel&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ISSUER &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;http://localhost:8080/realms/photos&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;AUDIENCE &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;photos-api&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;ALGORITHMS &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [&lt;span style="color:#e6db74"&gt;&amp;#34;RS256&amp;#34;&lt;/span&gt;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;JWKS_URL &lt;span style="color:#f92672"&gt;=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;f&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#e6db74"&gt;{&lt;/span&gt;ISSUER&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;/protocol/openid-connect/certs&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# PyJWKClient almacena JWKS y claves por kid; si llega un kid nuevo vuelve a pedir JWKS.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;jwks_client &lt;span style="color:#f92672"&gt;=&lt;/span&gt; PyJWKClient(JWKS_URL, cache_keys&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;True&lt;/span&gt;, lifespan&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;300&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;bearer &lt;span style="color:#f92672"&gt;=&lt;/span&gt; HTTPBearer(auto_error&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;False&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;app &lt;span style="color:#f92672"&gt;=&lt;/span&gt; FastAPI()&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;app&lt;span style="color:#f92672"&gt;.&lt;/span&gt;add_middleware(&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; CORSMiddleware,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; allow_origins&lt;span style="color:#f92672"&gt;=&lt;/span&gt;[&lt;span style="color:#e6db74"&gt;&amp;#34;http://localhost:5173&amp;#34;&lt;/span&gt;],&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; allow_credentials&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;False&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; allow_methods&lt;span style="color:#f92672"&gt;=&lt;/span&gt;[&lt;span style="color:#e6db74"&gt;&amp;#34;GET&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;POST&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;PATCH&amp;#34;&lt;/span&gt;],&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; allow_headers&lt;span style="color:#f92672"&gt;=&lt;/span&gt;[&lt;span style="color:#e6db74"&gt;&amp;#34;Authorization&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;Content-Type&amp;#34;&lt;/span&gt;],&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;PHOTOS: dict[str, dict[str, str]] &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;class&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;PhotoPatch&lt;/span&gt;(BaseModel):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; title: str&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;unauthorized&lt;/span&gt;(detail: str) &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; HTTPException:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; HTTPException(&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; status_code&lt;span style="color:#f92672"&gt;=&lt;/span&gt;status&lt;span style="color:#f92672"&gt;.&lt;/span&gt;HTTP_401_UNAUTHORIZED,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; detail&lt;span style="color:#f92672"&gt;=&lt;/span&gt;detail,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; headers&lt;span style="color:#f92672"&gt;=&lt;/span&gt;{&lt;span style="color:#e6db74"&gt;&amp;#34;WWW-Authenticate&amp;#34;&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;Bearer&amp;#34;&lt;/span&gt;},&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; )&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;current_claims&lt;/span&gt;(&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; credentials: HTTPAuthorizationCredentials &lt;span style="color:#f92672"&gt;|&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;None&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; Depends(bearer),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;) &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; dict:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; credentials &lt;span style="color:#f92672"&gt;is&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;None&lt;/span&gt; &lt;span style="color:#f92672"&gt;or&lt;/span&gt; credentials&lt;span style="color:#f92672"&gt;.&lt;/span&gt;scheme&lt;span style="color:#f92672"&gt;.&lt;/span&gt;lower() &lt;span style="color:#f92672"&gt;!=&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;bearer&amp;#34;&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; unauthorized(&lt;span style="color:#e6db74"&gt;&amp;#34;Missing bearer access token&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;try&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; signing_key &lt;span style="color:#f92672"&gt;=&lt;/span&gt; jwks_client&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get_signing_key_from_jwt(credentials&lt;span style="color:#f92672"&gt;.&lt;/span&gt;credentials)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; jwt&lt;span style="color:#f92672"&gt;.&lt;/span&gt;decode(&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; credentials&lt;span style="color:#f92672"&gt;.&lt;/span&gt;credentials,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; signing_key&lt;span style="color:#f92672"&gt;.&lt;/span&gt;key,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; algorithms&lt;span style="color:#f92672"&gt;=&lt;/span&gt;ALGORITHMS,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; issuer&lt;span style="color:#f92672"&gt;=&lt;/span&gt;ISSUER,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; audience&lt;span style="color:#f92672"&gt;=&lt;/span&gt;AUDIENCE,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; options&lt;span style="color:#f92672"&gt;=&lt;/span&gt;{&lt;span style="color:#e6db74"&gt;&amp;#34;require&amp;#34;&lt;/span&gt;: [&lt;span style="color:#e6db74"&gt;&amp;#34;exp&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;iss&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;sub&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;aud&amp;#34;&lt;/span&gt;]},&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; )&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;except&lt;/span&gt; ExpiredSignatureError:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; unauthorized(&lt;span style="color:#e6db74"&gt;&amp;#34;Access token expired&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;except&lt;/span&gt; (InvalidTokenError, PyJWKClientError):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; unauthorized(&lt;span style="color:#e6db74"&gt;&amp;#34;Invalid access token&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;require_scope&lt;/span&gt;(required: str):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;dependency&lt;/span&gt;(claims: dict &lt;span style="color:#f92672"&gt;=&lt;/span&gt; Depends(current_claims)) &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; dict:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; scopes &lt;span style="color:#f92672"&gt;=&lt;/span&gt; set(claims&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get(&lt;span style="color:#e6db74"&gt;&amp;#34;scope&amp;#34;&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#34;&amp;#34;&lt;/span&gt;)&lt;span style="color:#f92672"&gt;.&lt;/span&gt;split())&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; required &lt;span style="color:#f92672"&gt;not&lt;/span&gt; &lt;span style="color:#f92672"&gt;in&lt;/span&gt; scopes:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; HTTPException(status_code&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;403&lt;/span&gt;, detail&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;f&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Missing scope: &lt;/span&gt;&lt;span style="color:#e6db74"&gt;{&lt;/span&gt;required&lt;span style="color:#e6db74"&gt;}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; claims&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; dependency&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;require_editor&lt;/span&gt;(claims: dict) &lt;span style="color:#f92672"&gt;-&amp;gt;&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;None&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; roles &lt;span style="color:#f92672"&gt;=&lt;/span&gt; set(claims&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get(&lt;span style="color:#e6db74"&gt;&amp;#34;realm_access&amp;#34;&lt;/span&gt;, {})&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get(&lt;span style="color:#e6db74"&gt;&amp;#34;roles&amp;#34;&lt;/span&gt;, []))&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;photo-editor&amp;#34;&lt;/span&gt; &lt;span style="color:#f92672"&gt;not&lt;/span&gt; &lt;span style="color:#f92672"&gt;in&lt;/span&gt; roles:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; HTTPException(status_code&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;403&lt;/span&gt;, detail&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Editor role required&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@app.post&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;/photos&amp;#34;&lt;/span&gt;, status_code&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;201&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;create_photo&lt;/span&gt;(claims: dict &lt;span style="color:#f92672"&gt;=&lt;/span&gt; Depends(require_scope(&lt;span style="color:#e6db74"&gt;&amp;#34;photos:write&amp;#34;&lt;/span&gt;))):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; require_editor(claims)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; photo_id &lt;span style="color:#f92672"&gt;=&lt;/span&gt; str(uuid4())&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; PHOTOS[photo_id] &lt;span style="color:#f92672"&gt;=&lt;/span&gt; {&lt;span style="color:#e6db74"&gt;&amp;#34;owner_sub&amp;#34;&lt;/span&gt;: claims[&lt;span style="color:#e6db74"&gt;&amp;#34;sub&amp;#34;&lt;/span&gt;], &lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;Nueva foto&amp;#34;&lt;/span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; {&lt;span style="color:#e6db74"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;: photo_id, &lt;span style="color:#e6db74"&gt;&amp;#34;owner_sub&amp;#34;&lt;/span&gt;: claims[&lt;span style="color:#e6db74"&gt;&amp;#34;sub&amp;#34;&lt;/span&gt;]}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@app.get&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;/photos/&lt;/span&gt;&lt;span style="color:#e6db74"&gt;{photo_id}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;read_photo&lt;/span&gt;(photo_id: str, claims: dict &lt;span style="color:#f92672"&gt;=&lt;/span&gt; Depends(require_scope(&lt;span style="color:#e6db74"&gt;&amp;#34;photos:read&amp;#34;&lt;/span&gt;))):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; photo &lt;span style="color:#f92672"&gt;=&lt;/span&gt; PHOTOS&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get(photo_id)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; photo &lt;span style="color:#f92672"&gt;is&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;None&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; HTTPException(status_code&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;404&lt;/span&gt;, detail&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Photo not found&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; {&lt;span style="color:#e6db74"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;: photo_id, &lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt;: photo[&lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt;]}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;@app.patch&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#34;/photos/&lt;/span&gt;&lt;span style="color:#e6db74"&gt;{photo_id}&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;def&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;update_photo&lt;/span&gt;(&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; photo_id: str,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; patch: PhotoPatch,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; claims: dict &lt;span style="color:#f92672"&gt;=&lt;/span&gt; Depends(require_scope(&lt;span style="color:#e6db74"&gt;&amp;#34;photos:write&amp;#34;&lt;/span&gt;)),&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;):&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; require_editor(claims)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; photo &lt;span style="color:#f92672"&gt;=&lt;/span&gt; PHOTOS&lt;span style="color:#f92672"&gt;.&lt;/span&gt;get(photo_id)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; photo &lt;span style="color:#f92672"&gt;is&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;None&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; HTTPException(status_code&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;404&lt;/span&gt;, detail&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;Photo not found&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; photo[&lt;span style="color:#e6db74"&gt;&amp;#34;owner_sub&amp;#34;&lt;/span&gt;] &lt;span style="color:#f92672"&gt;!=&lt;/span&gt; claims[&lt;span style="color:#e6db74"&gt;&amp;#34;sub&amp;#34;&lt;/span&gt;]:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;raise&lt;/span&gt; HTTPException(status_code&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#ae81ff"&gt;403&lt;/span&gt;, detail&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;You do not own this photo&amp;#34;&lt;/span&gt;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; photo[&lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt;] &lt;span style="color:#f92672"&gt;=&lt;/span&gt; patch&lt;span style="color:#f92672"&gt;.&lt;/span&gt;title&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#66d9ef"&gt;return&lt;/span&gt; {&lt;span style="color:#e6db74"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;: photo_id, &lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt;: photo[&lt;span style="color:#e6db74"&gt;&amp;#34;title&amp;#34;&lt;/span&gt;]}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;PyJWKClient&lt;/code&gt; resuelve la clave por &lt;code&gt;kid&lt;/code&gt;, almacena claves y vuelve a obtener JWKS si no encuentra una; &lt;a href="https://pyjwt.readthedocs.io/en/stable/usage.html"&gt;PyJWT documenta ambos comportamientos&lt;/a&gt;. La API no confía en un &lt;code&gt;sub&lt;/code&gt; decodificado sin verificar la firma, ni acepta algoritmos distintos de los configurados. En producción añade HTTPS, límites, registros sin secretos, una base de datos transaccional y una política explícita para leer fotos privadas; el ejemplo deja la lectura visible para concentrarse en la escritura.&lt;/p&gt;&#10;&lt;h3 id="4-matriz-de-comprobación-manual"&gt;4. Matriz de comprobación manual&lt;/h3&gt;&#10;&lt;p&gt;Tras iniciar sesión como Alice, crea una foto mediante &lt;code&gt;POST /photos&lt;/code&gt; desde la SPA y guarda su id. Estos son los resultados que debe producir la configuración anterior:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Caso&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Petición&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Resultado esperado&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Lectura permitida&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Alice: &lt;code&gt;GET /photos/{id}&lt;/code&gt; con &lt;code&gt;photos:read&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;200.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Escritura propia&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Alice: &lt;code&gt;PATCH /photos/{id}&lt;/code&gt; con &lt;code&gt;photos:write&lt;/code&gt; y &lt;code&gt;photo-editor&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;200.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Scope/rol insuficiente&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Reader: &lt;code&gt;PATCH /photos/{id}&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;403 antes de modificar nada.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Foto ajena&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Bob: &lt;code&gt;PATCH /photos/{id}&lt;/code&gt; con write y editor&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;403: el &lt;code&gt;sub&lt;/code&gt; no coincide con &lt;code&gt;owner_sub&lt;/code&gt;.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Audiencia incorrecta&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Un access token sin &lt;code&gt;aud: photos-api&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;401 durante &lt;code&gt;jwt.decode&lt;/code&gt;.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Token expirado&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Reduce temporalmente el &lt;em&gt;Access Token Lifespan&lt;/em&gt; del realm, captura un token y úsalo después de &lt;code&gt;exp&lt;/code&gt;, sin llamar a &lt;code&gt;updateToken&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;401 &lt;code&gt;Access token expired&lt;/code&gt;.&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Para el quinto caso crea un segundo cliente público idéntico pero &lt;strong&gt;sin&lt;/strong&gt; el Audience mapper, inicia sesión en él y llama a la API: el validador no debe aceptar un token emitido para otra audiencia. No confundas este rechazo con 403: la credencial no cumple el contrato de esa API.&lt;/p&gt;&#10;&lt;p&gt;Una alternativa especialmente atractiva para aplicaciones web es un &lt;strong&gt;BFF&lt;/strong&gt; (&lt;em&gt;Backend for Frontend&lt;/em&gt;): el servidor confidencial realiza el canje de código y guarda tokens del lado servidor; el navegador solo recibe una cookie de sesión &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt; y con &lt;code&gt;SameSite&lt;/code&gt; apropiado. Esa cookie reduce la exposición directa de tokens a JavaScript, pero introduce defensas CSRF: validar &lt;code&gt;Origin&lt;/code&gt;/&lt;code&gt;Referer&lt;/code&gt; cuando corresponda, usar un token antiforgery en operaciones que cambian estado y no abrir CORS ni cookies a cualquier origen.&lt;/p&gt;&#10;&lt;h2 id="facebook-consentimiento-no-equivale-a-acceso-ilimitado"&gt;Facebook: consentimiento no equivale a acceso ilimitado&lt;/h2&gt;&#10;&lt;p&gt;Facebook es un buen recordatorio de que un scope no opera en el vacío. Sus permisos, el modo de la aplicación, las cuentas que pueden probarla y la revisión de la aplicación condicionan qué datos puede recibir un cliente, incluso tras el consentimiento de la persona. La documentación oficial de &lt;a href="https://developers.facebook.com/docs/permissions/reference/user_friends/"&gt;&lt;code&gt;user_friends&lt;/code&gt;&lt;/a&gt; limita el resultado a amistades que también usan la aplicación y han concedido ese permiso; no permite recorrer libremente amigos de amigos. Los permisos que requieren acceso avanzado están sujetos a las políticas y al proceso de &lt;a href="https://developers.facebook.com/docs/apps/review/"&gt;App Review&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Por tanto, «el usuario aceptó» no prueba que una API vaya a devolver un dato: el proveedor puede limitarlo, la aplicación puede no estar aprobada y la API del recurso conserva su propia política. Es el mismo principio de nuestra galería: el consentimiento y el token son entradas de la decisión, no sustitutos de ella.&lt;/p&gt;&#10;&lt;h2 id="fuentes-y-lecturas-para-contrastar"&gt;Fuentes y lecturas para contrastar&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc6749"&gt;OAuth 2.0 (RFC 6749)&lt;/a&gt;, &lt;a href="https://datatracker.ietf.org/doc/html/rfc9700"&gt;OAuth 2.0 Security BCP (RFC 9700)&lt;/a&gt; y &lt;a href="https://datatracker.ietf.org/doc/rfc10017/"&gt;OAuth para aplicaciones de navegador (RFC 10017)&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openid.net/specs/openid-connect-core-1_0-18.html"&gt;OpenID Connect Core 1.0&lt;/a&gt; y &lt;a href="https://openid.net/specs/openid-connect-discovery-1_0.html"&gt;OpenID Connect Discovery&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf"&gt;SAML 2.0 Core&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7519"&gt;JWT (RFC 7519)&lt;/a&gt;, &lt;a href="https://datatracker.ietf.org/doc/html/rfc7517"&gt;JWK/JWKS (RFC 7517)&lt;/a&gt; e &lt;a href="https://datatracker.ietf.org/doc/html/rfc7662"&gt;introspección de tokens (RFC 7662)&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.keycloak.org/securing-apps/javascript-adapter"&gt;Keycloak JavaScript adapter&lt;/a&gt;, &lt;a href="https://pyjwt.readthedocs.io/en/stable/usage.html"&gt;PyJWT: uso de PyJWKClient&lt;/a&gt; y &lt;a href="https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/"&gt;FastAPI: seguridad&lt;/a&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://developers.facebook.com/docs/permissions/reference/user_friends/"&gt;Permiso &lt;code&gt;user_friends&lt;/code&gt; de Facebook&lt;/a&gt; y &lt;a href="https://developers.facebook.com/docs/apps/review/"&gt;Facebook App Review&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item></channel></rss>