Files
oauth/docs/integracion-iam.html
Josep Piguillem c2b5291a74 Initial commit: OAuth IAM Laravel application
Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and
Docker Compose setup for local and production deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 01:02:41 +02:00

603 lines
24 KiB
HTML

<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Integrar tu app con este servidor de identidad (IAM)</title>
<style>
@page {
margin: 22mm 18mm;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
color: #1f2430;
font-size: 12.5px;
line-height: 1.55;
}
.cover {
height: 245mm;
display: flex;
flex-direction: column;
justify-content: center;
page-break-after: always;
}
.cover .badge {
display: inline-block;
background: #4f46e5;
color: white;
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
padding: 4px 12px;
border-radius: 999px;
margin-bottom: 18px;
width: fit-content;
}
.cover h1 {
font-size: 32px;
margin: 0 0 10px;
color: #111827;
}
.cover p.subtitle {
font-size: 15px;
color: #4b5563;
max-width: 440px;
margin: 0 0 28px;
}
.cover .meta {
font-size: 11.5px;
color: #9ca3af;
border-top: 1px solid #e5e7eb;
padding-top: 12px;
width: fit-content;
}
h1, h2, h3 { color: #111827; }
h2 {
font-size: 19px;
margin-top: 0;
padding-bottom: 8px;
border-bottom: 2px solid #4f46e5;
}
h3 {
font-size: 14.5px;
margin-bottom: 6px;
color: #4338ca;
}
section {
page-break-before: always;
padding-top: 4mm;
}
section:first-of-type { page-break-before: avoid; }
p { margin: 6px 0 10px; }
ul, ol { margin: 6px 0 12px; padding-left: 20px; }
li { margin-bottom: 4px; }
code {
background: #f1f0fb;
color: #4338ca;
padding: 1px 5px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
}
pre {
background: #1e1e2e;
color: #e2e2f5;
padding: 12px 14px;
border-radius: 8px;
overflow-x: auto;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 10.3px;
line-height: 1.5;
page-break-inside: avoid;
}
pre code { background: none; color: inherit; padding: 0; }
.lang-tag {
display: inline-block;
background: #111827;
color: #fff;
font-size: 10px;
font-weight: 600;
letter-spacing: .03em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 4px 4px 0 0;
margin-bottom: -1px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 16px;
font-size: 11.5px;
page-break-inside: avoid;
}
th, td {
border: 1px solid #e5e7eb;
padding: 6px 9px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f5fb;
color: #374151;
font-weight: 600;
}
tr:nth-child(even) td { background: #fafafa; }
.box {
background: #f5f5fb;
border-left: 4px solid #4f46e5;
padding: 10px 14px;
border-radius: 6px;
margin: 12px 0;
font-size: 11.8px;
}
.box.warn {
background: #fffbeb;
border-left-color: #d97706;
}
.box.danger {
background: #fef2f2;
border-left-color: #dc2626;
}
.box strong { color: #111827; }
.toc { list-style: none; padding-left: 0; }
.toc li {
display: flex;
justify-content: space-between;
border-bottom: 1px dotted #d1d5db;
padding: 5px 0;
font-size: 13px;
}
.toc .n { color: #4f46e5; font-weight: 600; margin-right: 8px; }
.pill {
display: inline-block;
background: #eef2ff;
color: #4338ca;
border-radius: 4px;
padding: 1px 7px;
font-size: 10.5px;
font-weight: 600;
margin-right: 4px;
}
.stack-grid {
display: flex;
gap: 10px;
margin: 12px 0 18px;
}
.stack-card {
flex: 1;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
}
.stack-card h4 {
margin: 0 0 4px;
font-size: 12px;
color: #4338ca;
}
.stack-card p {
margin: 0;
font-size: 10.8px;
color: #6b7280;
}
footer.pagefoot {
margin-top: 22px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
font-size: 10px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="cover">
<span class="badge">Guía para desarrolladores</span>
<h1>Integrar tu app con este servidor de identidad</h1>
<p class="subtitle">Cómo conectar Angular, NestJS, Flutter (o cualquier otro stack) a este servidor OAuth2
para usarlo como IAM centralizado: login, usuarios, roles globales y roles por-app.</p>
<div class="meta">
Laravel 12 · Passport 13 (OAuth2, tokens JWT firmados RS256)<br>
Generado el 25 de julio de 2026
</div>
</div>
<section>
<h2>Índice</h2>
<ul class="toc">
<li><span><span class="n">1.</span>Qué te da este servidor</span></li>
<li><span><span class="n">2.</span>Paso 1 — Registrar tu app como cliente OAuth</span></li>
<li><span><span class="n">3.</span>Elegí el flujo según tu arquitectura</span></li>
<li><span><span class="n">4.</span>Angular (SPA) — Authorization Code + PKCE</span></li>
<li><span><span class="n">5.</span>NestJS — backend con login de usuarios</span></li>
<li><span><span class="n">6.</span>NestJS — servicio backend-a-backend (Client Credentials)</span></li>
<li><span><span class="n">7.</span>Flutter / apps móviles</span></li>
<li><span><span class="n">8.</span>Validar el token en tu backend</span></li>
<li><span><span class="n">9.</span>Roles: global vs. por-app</span></li>
<li><span><span class="n">10.</span>Refrescar y revocar tokens</span></li>
<li><span><span class="n">11.</span>Checklist de seguridad</span></li>
<li><span><span class="n">12.</span>Referencia rápida</span></li>
</ul>
</section>
<section>
<h2>1. Qué te da este servidor</h2>
<p>
Es un <strong>servidor de identidad OAuth2</strong> (Laravel Passport). En vez de que cada app tuya tenga su
propia tabla de usuarios y su propio login, todas hablan con este servidor: acá vive el usuario, la
contraseña, y quién es qué.
</p>
<ul>
<li><strong>Login centralizado</strong> — un solo lugar donde el usuario pone su contraseña. Tu app nunca
la ve: recibe un <em>access token</em> después de que el usuario aprueba el acceso.</li>
<li><strong>Datos de usuario vía API</strong> — <code>GET /api/user</code> con el token te devuelve nombre,
email, y sus roles.</li>
<li><strong>Roles globales y roles por-app</strong> — un usuario puede ser <code>admin</code> en el sistema
en general, pero además tener un rol <em>específico de tu app</em> (por ejemplo <code>editor</code> en la
tuya y nada en la de otro equipo). Ver sección 9.</li>
<li><strong>Gestión completa por API</strong> — si querés armar tu propio panel de administración de
usuarios (en vez de usar el que trae este servidor), toda la funcionalidad está expuesta en
<code>/api/*</code> con Bearer token. No hace falta sesión ni cookies de este servidor.</li>
</ul>
<div class="box">
<strong>Dos capas de permisos, no confundirlas:</strong> los <em>scopes</em> del token OAuth (
<code>users:read</code>, <code>users:write</code>...) definen qué puede hacer <em>ese token</em> contra la
API de este servidor. Los <em>roles por-app</em> definen qué puede hacer <em>ese usuario dentro de tu app</em>
— son datos que vos leés e interpretás como quieras, este servidor no te impone nada sobre cómo usarlos.
</div>
</section>
<section>
<h2>2. Paso 1 — Registrar tu app como cliente OAuth</h2>
<p>Cada aplicación que se conecte necesita ser un <em>cliente</em> registrado. Se hace una sola vez, desde el
panel (<code>/clients</code>, con cualquier cuenta) o por API:</p>
<pre><code>curl -X POST http://localhost:8000/api/clients \
-H "Authorization: Bearer {tu_access_token}" \
-H "Content-Type: application/json" \
-d '{
"name": "Mi App Angular",
"grant_type": "authorization_code",
"redirect_uris": ["https://mi-app.com/callback"],
"confidential": false
}'</code></pre>
<p>La respuesta trae <code>client.id</code> (el <code>client_id</code>) y, si es confidencial,
<code>plain_secret</code> — <strong>se muestra una sola vez</strong>, guardalo ya.</p>
<table>
<tr><th>Campo</th><th>Qué significa</th></tr>
<tr><td><code>grant_type</code></td><td><code>authorization_code</code>, <code>client_credentials</code> o <code>password</code> — ver sección 3 para elegir.</td></tr>
<tr><td><code>redirect_uris</code></td><td>A dónde te devuelve el navegador después del login. Tiene que matchear exacto con lo que mandes en <code>/oauth/authorize</code>.</td></tr>
<tr><td><code>confidential</code></td><td><code>false</code> = cliente público, sin secret (SPA, apps móviles — usan PKCE en su lugar). <code>true</code> = confidencial, con secret (backends que pueden guardar un secreto de forma segura).</td></tr>
</table>
</section>
<section>
<h2>3. Elegí el flujo según tu arquitectura</h2>
<div class="stack-grid">
<div class="stack-card">
<h4>SPA pura (Angular, React, Vue)</h4>
<p>Sin backend propio, corre 100% en el navegador. <strong>Authorization Code + PKCE</strong>, cliente
público. El token queda en el navegador.</p>
</div>
<div class="stack-card">
<h4>App con backend (NestJS, Express)</h4>
<p>El backend maneja el login por vos (patrón BFF). <strong>Authorization Code</strong>, cliente
confidencial — el secret vive en el backend, nunca llega al navegador.</p>
</div>
<div class="stack-card">
<h4>App móvil (Flutter, RN)</h4>
<p><strong>Authorization Code + PKCE</strong>, cliente público, con un redirect por deep link / custom
scheme.</p>
</div>
<div class="stack-card">
<h4>Servicio sin usuario final</h4>
<p>Un cron, un worker, otro backend que llama a la API por su cuenta. <strong>Client Credentials</strong>.</p>
</div>
</div>
<div class="box">
<strong>Password Grant existe pero evitalo</strong> salvo que sea estrictamente first-party (una app que vos
mismo controlás de punta a punta): tu app le pide el usuario y contraseña directamente al usuario y se los
manda a este servidor. Ningún cliente de terceros debería tocar la contraseña del usuario.
</div>
</section>
<section>
<h2>4. Angular (SPA) — Authorization Code + PKCE</h2>
<p>Ejemplo con <code>angular-oauth2-oidc</code> (también podés implementarlo a mano, son cuatro pasos: generar
verifier/challenge, redirigir, recibir el <code>code</code>, canjearlo).</p>
<span class="lang-tag">app.config.ts</span>
<pre><code>import { provideOAuthClient, OAuthModule } from 'angular-oauth2-oidc';
export const authConfig = {
issuer: 'http://localhost:8000',
loginUrl: 'http://localhost:8000/oauth/authorize',
tokenEndpoint: 'http://localhost:8000/oauth/token',
redirectUri: window.location.origin + '/callback',
clientId: '{tu_client_id}',
responseType: 'code',
scope: 'users:read account:read',
useHttpBasicAuth: false,
disablePKCE: false, // clave: PKCE prendido, es cliente público
oidc: false, // este servidor es OAuth2 puro, no expone id_token/OIDC
};</code></pre>
<span class="lang-tag">auth.service.ts</span>
<pre><code>import { Injectable } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private oauth: OAuthService) {}
login() {
this.oauth.initCodeFlow(); // redirige a /oauth/authorize con PKCE
}
async me() {
const token = this.oauth.getAccessToken();
const res = await fetch('http://localhost:8000/api/user', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json(); // { id, name, email, roles, app_roles, ... }
}
}</code></pre>
<p>Si preferís no traer una librería, el flujo manual es: generar <code>code_verifier</code> (random) y
<code>code_challenge</code> (SHA-256 + base64url del verifier), armar la URL de
<code>/oauth/authorize</code> con esos parámetros + <code>state</code>, y en el callback hacer
<code>POST /oauth/token</code> con <code>grant_type=authorization_code</code>, el <code>code</code> recibido y
el <code>code_verifier</code> original.</p>
</section>
<section>
<h2>5. NestJS — backend con login de usuarios</h2>
<p>Tu backend actúa de cliente <strong>confidencial</strong> (guarda el secret) y maneja el flujo Authorization
Code por el usuario — patrón "backend for frontend": el navegador nunca ve el access token, solo una cookie de
sesión propia de tu app.</p>
<span class="lang-tag">auth.controller.ts</span>
<pre><code>@Controller('auth')
export class AuthController {
private readonly IAM = 'http://localhost:8000';
private readonly CLIENT_ID = process.env.IAM_CLIENT_ID;
private readonly CLIENT_SECRET = process.env.IAM_CLIENT_SECRET;
@Get('login')
login(@Res() res: Response) {
const params = new URLSearchParams({
client_id: this.CLIENT_ID,
redirect_uri: 'https://mi-backend.com/auth/callback',
response_type: 'code',
scope: 'users:read',
state: crypto.randomUUID(),
});
res.redirect(`${this.IAM}/oauth/authorize?${params}`);
}
@Get('callback')
async callback(@Query('code') code: string, @Session() session: Record<string, any>) {
const { data } = await axios.post(`${this.IAM}/oauth/token`, {
grant_type: 'authorization_code',
client_id: this.CLIENT_ID,
client_secret: this.CLIENT_SECRET,
code,
redirect_uri: 'https://mi-backend.com/auth/callback',
});
session.accessToken = data.access_token; // guardalo del lado del servidor
session.refreshToken = data.refresh_token;
}
}</code></pre>
<p>Como es cliente confidencial, no hace falta PKCE (aunque no molesta agregarlo igual). El token queda
guardado en la sesión del backend, nunca expuesto al navegador.</p>
</section>
<section>
<h2>6. NestJS — servicio backend-a-backend (Client Credentials)</h2>
<p>Para un servicio que consulta/gestiona usuarios por su cuenta, sin un usuario logueado de por medio —
scheduler, worker, integración entre sistemas.</p>
<span class="lang-tag">iam-client.service.ts</span>
<pre><code>@Injectable()
export class IamClientService {
private token: string | null = null;
private expiresAt = 0;
private async getToken(): Promise<string> {
if (this.token && Date.now() < this.expiresAt) return this.token;
const { data } = await axios.post('http://localhost:8000/oauth/token', {
grant_type: 'client_credentials',
client_id: process.env.IAM_SERVICE_CLIENT_ID,
client_secret: process.env.IAM_SERVICE_CLIENT_SECRET,
scope: 'users:read users:write',
});
this.token = data.access_token;
this.expiresAt = Date.now() + (data.expires_in - 60) * 1000; // renovar 1 min antes
return this.token;
}
async listUsers() {
const token = await this.getToken();
const { data } = await axios.get('http://localhost:8000/api/users', {
headers: { Authorization: `Bearer ${token}` },
});
return data;
}
}</code></pre>
<div class="box">
Un token de <code>client_credentials</code> no tiene usuario asociado: actúa como "servicio de confianza".
En este servidor eso significa que puede usar <code>/api/users</code> y <code>/api/apps</code> con el
scope como único chequeo (sin necesitar un permiso de usuario admin) — pero <strong>no</strong> puede tocar
recursos "de cuenta propia" (<code>/api/clients</code>, personal access tokens, apps autorizadas), porque
esos son inherentemente de un usuario y no tiene ninguno asociado.
</div>
</section>
<section>
<h2>7. Flutter / apps móviles</h2>
<p>Con el paquete <code>flutter_appauth</code> (implementa Authorization Code + PKCE con el navegador del
sistema, que es lo recomendado — evita usar un WebView embebido).</p>
<span class="lang-tag">auth_service.dart</span>
<pre><code>final appAuth = FlutterAppAuth();
Future&lt;String?&gt; login() async {
final result = await appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
'{tu_client_id}',
'com.miapp://callback', // custom scheme registrado en tu app
serviceConfiguration: const AuthorizationServiceConfiguration(
authorizationEndpoint: 'http://localhost:8000/oauth/authorize',
tokenEndpoint: 'http://localhost:8000/oauth/token',
),
scopes: ['users:read'],
),
);
return result.accessToken;
}
Future&lt;Map&lt;String, dynamic&gt;&gt; me(String token) async {
final res = await http.get(
Uri.parse('http://localhost:8000/api/user'),
headers: {'Authorization': 'Bearer $token'},
);
return jsonDecode(res.body);
}</code></pre>
<p>El <code>redirect_uri</code> (<code>com.miapp://callback</code>) tiene que estar registrado exactamente
igual en el cliente OAuth (sección 2) y configurado en tu app nativa (Android: intent filter; iOS: URL scheme
en el <code>Info.plist</code>).</p>
</section>
<section>
<h2>8. Validar el token en tu backend</h2>
<p>Cuando tu backend recibe un request con <code>Authorization: Bearer {token}</code> y necesita confirmar que
es válido, hay dos caminos:</p>
<h3>Opción A — Preguntarle a este servidor (más simple)</h3>
<pre><code>GET /api/user
Authorization: Bearer {token}</code></pre>
<p>Si responde <code>200</code> con los datos del usuario, el token es válido. Es un request de red extra por
cada llamada, pero es siempre exacto: refleja revocaciones al instante.</p>
<h3>Opción B — Validar el JWT localmente (sin red)</h3>
<p>Los access tokens de este servidor son <strong>JWT firmados con RS256</strong> (no opacos). Tu backend puede
verificar la firma con la clave pública de Passport (<code>storage/oauth-public.key</code> en este proyecto —
pedísela al equipo que administra este servidor) sin llamarlo por red:</p>
<pre><code>// Node.js, con la librería "jsonwebtoken"
import jwt from 'jsonwebtoken';
import fs from 'fs';
const publicKey = fs.readFileSync('oauth-public.key');
function verify(token: string) {
return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// -> { aud, jti, iat, nbf, exp, sub, scopes: string[] }
}</code></pre>
<p>Claims relevantes: <code>sub</code> = id del usuario (ausente en tokens de <code>client_credentials</code>),
<code>aud</code> = <code>client_id</code> que lo pidió, <code>scopes</code> = array de scopes otorgados,
<code>exp</code> = expiración (unix timestamp).</p>
<div class="box warn">
<strong>Trade-off:</strong> la validación local es más rápida (sin red) pero no se entera si el token fue
<em>revocado</em> antes de su expiración natural — solo valida firma y expiración. Para casos sensibles
(borrar acceso ya mismo), usá la Opción A o combiná: validación local + chequeo periódico contra el
servidor.
</div>
</section>
<section>
<h2>9. Roles: global vs. por-app</h2>
<p><code>GET /api/user</code> (o <code>/api/users/{id}</code>) devuelve, entre otros campos:</p>
<pre><code>{
"id": 2,
"name": "Carla Gómez",
"email": "carla@example.com",
"roles": ["user"],
"app_roles": ["editor"]
}</code></pre>
<ul>
<li><code>roles</code> — el rol <strong>global</strong> del usuario en este servidor (<code>admin</code> o
<code>user</code>). No dice nada sobre qué puede hacer en <em>tu</em> app.</li>
<li><code>app_roles</code> — los roles que un admin le asignó a ese usuario <strong>específicamente para el
cliente OAuth que hizo la request</strong> (o sea: tu app). Se resuelve solo, según el <code>client_id</code>
del token usado — no hace falta pedir nada extra.</li>
</ul>
<p>Un admin asigna estos roles desde <code>/admin/users</code> → editar usuario → "Roles por app", eligiendo tu
app de la lista. Interpretalos como quieras del lado tuyo (un guard de NestJS, un route guard de Angular, etc.)
— este servidor solo los guarda y te los devuelve.</p>
<span class="lang-tag">nestjs guard (ejemplo)</span>
<pre><code>@Injectable()
export class AppRoleGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
const req = context.switchToHttp().getRequest();
const user = await this.iam.me(req.headers.authorization);
return user.app_roles.includes('editor');
}
}</code></pre>
</section>
<section>
<h2>10. Refrescar y revocar tokens</h2>
<p>Los access tokens expiran (15 días por defecto en este servidor). Si tu cliente pidió el scope
<code>refresh_token</code> implícito del grant (viene incluido en <code>authorization_code</code> y
<code>password</code>), la respuesta de <code>/oauth/token</code> trae un <code>refresh_token</code> — usalo
así, sin volver a pasar por el login:</p>
<pre><code>curl -X POST http://localhost:8000/oauth/token \
-d grant_type=refresh_token \
-d refresh_token={tu_refresh_token} \
-d client_id={tu_client_id} \
-d client_secret={tu_client_secret} # si tu cliente es confidencial</code></pre>
<p><code>client_credentials</code> no tiene refresh token — simplemente pedís uno nuevo cuando el anterior
expira (ver el ejemplo de NestJS en la sección 6).</p>
<p>El usuario puede revocarte el acceso en cualquier momento desde <code>/authorized-apps</code> en el panel de
este servidor — a partir de ahí tus requests con ese token empiezan a devolver <code>401</code>.</p>
</section>
<section>
<h2>11. Checklist de seguridad</h2>
<ul>
<li>☐ Clientes públicos (SPA, móvil) — <strong>siempre PKCE</strong>, nunca les asignes un secret que
tendrían que embeber en código que corre en el dispositivo del usuario.</li>
<li>☐ Pedí solo los <strong>scopes que realmente necesitás</strong> — no hay scope por defecto en este
servidor, es intencional.</li>
<li>☐ En una SPA, si podés, no guardes el access token en <code>localStorage</code> (vulnerable a XSS) —
preferí el patrón backend-for-frontend (sección 5) con cookie <code>httpOnly</code>.</li>
<li>☐ Verificá siempre el parámetro <code>state</code> en el callback de Authorization Code (protección
CSRF del flujo).</li>
<li>☐ Los <code>client_secret</code> de clientes confidenciales van en variables de entorno del backend,
nunca en el repo ni en el bundle del frontend.</li>
<li>☐ Si tu servicio backend-a-backend solo necesita leer, pedile a un admin que te dé un cliente con scope
<code>users:read</code> nada más (no <code>users:write</code>).</li>
</ul>
</section>
<section>
<h2>12. Referencia rápida</h2>
<table>
<tr><th>Endpoint</th><th>Para qué</th></tr>
<tr><td><code>GET /oauth/authorize</code></td><td>Inicio del flujo Authorization Code (redirige al login/consentimiento).</td></tr>
<tr><td><code>POST /oauth/token</code></td><td>Canjear código, credenciales o refresh token por un access token.</td></tr>
<tr><td><code>GET /api/user</code></td><td>Whoami — usuario del token actual + roles global y por-app.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/users</code></td><td>Directorio de usuarios (scope <code>users:read</code>/<code>write</code>).</td></tr>
<tr><td><code>GET/POST/DELETE /api/users/{id}/app-roles</code></td><td>Roles por-app de un usuario (para asignarlos vos mismo por API, si tenés permiso).</td></tr>
<tr><td><code>GET /api/apps</code></td><td>Listado de apps (clientes OAuth) registradas.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/clients</code></td><td>Gestionar tus propios clientes OAuth por API.</td></tr>
</table>
<table>
<tr><th>Scope</th><th>Da acceso a</th></tr>
<tr><td><code>users:read</code></td><td>Leer usuarios y sus roles (global + por-app).</td></tr>
<tr><td><code>users:write</code></td><td>Crear/editar/borrar usuarios, asignar roles por-app.</td></tr>
<tr><td><code>account:read</code></td><td>Leer tus propios clientes OAuth, tokens y apps autorizadas.</td></tr>
<tr><td><code>account:write</code></td><td>Gestionar tus propios clientes OAuth, tokens y apps autorizadas.</td></tr>
</table>
<p>Documentación interactiva (Swagger, para probar todo esto desde el navegador) en
<code>/api/documentation</code>. Manual completo del panel de administración en
<code>docs/manual-usuario.pdf</code>.</p>
<footer class="pagefoot">Guía de integración IAM — generada automáticamente a partir del estado actual del proyecto.</footer>
</section>
</body>
</html>