---
name: auth-patrones
description: Patrones de autenticación y autorización. JWT, OAuth2, OIDC, RBAC, ABAC, session management, refresh tokens, PKCE, MFA. Implementaciones en FastAPI y Django. Reglas anti-error de seguridad críticas.
version: "1.2.0"
evolved: true
evolved-from: "1.1.0"
evolved-at: "2026-04-24"
evolved-by: "aprender"
evolved-note: "2 secciones nuevas: IDOR defense con 404 (no 403) + consent PII en TODOS los ambientes"
herramientasPermitidas: [Read, Grep]
nist_csf: [PR.AA-01, PR.AA-02, PR.AA-03, PR.AA-04, PR.AA-05]
attack_techniques: [T1078, T1110, T1212]
d3fend_techniques: [D3-MFA]
exclusiones:
  - "No cargar para seguridad de infraestructura (firewall, VPN, TLS, certificados) — este skill cubre autenticación de aplicación, no seguridad de red."
  - "No cargar para gestión de secretos en cloud (AWS Secrets Manager, Vault, Azure Key Vault) — para infraestructura de secretos cargar `cloud-infra`."
  - "No cargar para auditorías de seguridad de código o OWASP Top 10 completo — para revisión de seguridad cargar la regla `seguridad.md`."
  - "No cargar para implementaciones de auth en Rails (Devise) o Laravel (Sanctum/Passport) — las implementaciones aquí son para FastAPI/Django; para Rails cargar `rails-experto`."
evolvable: true  # default para skill estandar
---
# Autenticación y Autorización — Patrones de Seguridad

## Cuándo NO cargar

- La tarea es seguridad de infraestructura: firewall, VPN, TLS, configuración de certificados — este skill cubre autenticación en la aplicación, no seguridad de red.
- El tema es gestión de secretos en cloud: AWS Secrets Manager, HashiCorp Vault, Azure Key Vault — para infraestructura de secretos cargar `cloud-infra`.
- La tarea es auditoría de seguridad de código o revisión OWASP Top 10 — cargar la regla `seguridad.md`.
- El framework es Rails (Devise) o Laravel (Sanctum/Passport) — las implementaciones aquí son para Python/FastAPI/Django; para Rails cargar `rails-experto`.

La autenticación ("¿quién eres?") y la autorización ("¿qué puedes hacer?") son las
capas de seguridad más críticas en cualquier sistema. Un error aquí compromete todo lo demás.

---

## 1. JWT — JSON Web Tokens

```python
import os
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt

SECRET_KEY = os.environ["JWT_SECRET_KEY"]  # NUNCA hardcodear — min 256 bits
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def crear_access_token(payload: dict) -> str:
    datos = payload.copy()
    datos["exp"] = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    datos["iat"] = datetime.now(timezone.utc)
    datos["type"] = "access"
    return jwt.encode(datos, SECRET_KEY, algorithm=ALGORITHM)

def verificar_token(token: str, tipo_esperado: str = "access") -> dict:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError as e:
        raise CredencialesInvalidas("Token inválido o expirado") from e
    if payload.get("type") != tipo_esperado:
        raise CredencialesInvalidas(f"Se esperaba token de tipo '{tipo_esperado}'")
    return payload
```

### Claims OBLIGATORIOS: `sub`, `exp`, `iat`, `type`, `jti` (para revocación)

---

## 2. FastAPI — Auth Completo

```python
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db),
) -> Usuario:
    credenciales_exc = HTTPException(status_code=401, detail="No autenticado")
    try:
        payload = verificar_token(token, tipo_esperado="access")
        user_id = payload.get("sub", "")
        if not user_id:
            raise credenciales_exc
    except CredencialesInvalidas:
        raise credenciales_exc
    usuario = await db.get(Usuario, user_id)
    if usuario is None or not usuario.activo:
        raise credenciales_exc
    return usuario

def require_role(roles_permitidos: list[str]):
    async def verificar_rol(usuario: Usuario = Depends(get_current_user)) -> Usuario:
        if not {r.nombre for r in usuario.roles}.intersection(roles_permitidos):
            raise HTTPException(status_code=403, detail="Sin permisos")
        return usuario
    return verificar_rol
```

---

## 3. RBAC — Control de Acceso Basado en Roles

```python
PERMISOS: dict[str, list[str]] = {
    "empleados:crear":   ["ADMIN", "RRHH"],
    "empleados:editar":  ["ADMIN", "RRHH"],
    "empleados:ver":     ["ADMIN", "RRHH", "NOMINA", "AUDITOR"],
    "nomina:procesar":   ["ADMIN", "NOMINA"],
}

# ABAC — combinar rol + contexto
def puede_ver_expediente(usuario: Usuario, expediente: Expediente) -> bool:
    """Un empleado solo puede ver su propio expediente."""
    if "ADMIN" in {r.nombre for r in usuario.roles}:
        return True
    if "RRHH" in {r.nombre for r in usuario.roles}:
        return expediente.departamento_id == usuario.departamento_id
    return expediente.empleado_id == usuario.id
```

---

## 4. IDOR — Insecure Direct Object Reference

SIEMPRE verificar que el recurso pertenece al usuario autenticado:

```python
# MAL: sin verificar propiedad
return await db.get(Expediente, id)

# BIEN: verificar autorización
expediente = await db.get(Expediente, id)
if not puede_ver_expediente(usuario, expediente):
    raise HTTPException(403, "Sin autorización")
```

---

## Implementaciones Completas

Para Refresh Token con rotación, OAuth2/OIDC con authlib, Passwords (bcrypt + validación),
MFA con TOTP (pyotp + QR), y Headers de seguridad HTTP, ver
[recursos/implementaciones-completas.md](recursos/implementaciones-completas.md).

---

## Gotchas / Errores comunes no obvios

**JWT con `alg: none` aceptado por librerías mal configuradas permite falsificar tokens sin firma**: algunas versiones de `python-jose` y `PyJWT` sin la opción `algorithms=` explícita aceptaban `alg: none` — cualquier payload sin firma era válido. Causa: el estándar JWT permite `alg: none` para tokens no firmados; las librerías que no restringen el algoritmo aceptan esto. Fix: SIEMPRE especificar `algorithms=["HS256"]` (o el algoritmo usado) al decodificar; NUNCA pasar `options={"verify_signature": False}` en producción.

**Refresh token almacenado en `localStorage` es vulnerable a XSS aunque el access token esté en httpOnly cookie**: si el refresh token está en `localStorage` y hay una vulnerabilidad XSS en la app, el atacante puede robar el refresh token y obtener nuevos access tokens indefinidamente. Causa: `localStorage` es accesible por JavaScript — cualquier script en la página puede leerlo. Fix: almacenar tanto el access token como el refresh token en cookies `httpOnly; Secure; SameSite=Strict`; usar un endpoint `/auth/refresh` que lea la cookie httpOnly y retorne un nuevo access token.

**`require_role` implementado como dependencia de FastAPI no se aplica a rutas OPTIONS (preflight CORS)**: el decorador de RBAC en FastAPI bloquea las peticiones OPTIONS del preflight si el middleware CORS no intercepta antes. Esto causa errores `403 Forbidden` en peticiones cross-origin aunque el usuario tenga el rol correcto. Causa: FastAPI ejecuta las dependencias antes de que el middleware CORS responda OPTIONS. Fix: configurar el middleware CORS de FastAPI con `CORSMiddleware` ANTES de cualquier middleware de auth, o excluir explícitamente el método `OPTIONS` de las verificaciones de auth.

**Tokens JWT con `exp` muy largo (7+ días) no se pueden invalidar después de un compromiso**: si un access token de 7 días es robado, el usuario no tiene forma de invalidarlo porque JWT es stateless — el servidor no puede verificar si fue revocado. Causa: el diseño stateless de JWT. Fix: mantener access tokens con expiración corta (15-30 min) y usar una allowlist/denylist de `jti` en Redis para tokens revocados; invalidar el `jti` al logout o al detectar actividad sospechosa.

## Multitenancy: queries auth-aware desde el repositorio

La prevención de IDOR capa por capa (middleware + controlador + servicio) es
frágil — si una nueva ruta olvida el check, el bug pasa a producción
silenciosamente. **Patrón recomendado (observado en Polar y otros sistemas
SaaS multitenant): empujar el filtro de `auth_subject` al repositorio**, no
al servicio. El repositorio expone un método `get_readable_statement(auth_subject)`
que retorna un `Select` ya filtrado. Cualquier query de lectura parte de ese
statement base.

```python
# repository.py — SQLAlchemy 2.0 async
class ResourceRepository:
    model = Resource

    def get_readable_statement(
        self, auth_subject: AuthSubject[User | Organization]
    ) -> Select[tuple[Resource]]:
        statement = select(self.model)
        if is_user(auth_subject):
            statement = statement.where(
                Resource.organization_id.in_(
                    select(UserOrganization.organization_id).where(
                        UserOrganization.user_id == auth_subject.subject.id
                    )
                )
            )
        elif is_organization(auth_subject):
            statement = statement.where(
                Resource.organization_id == auth_subject.subject.id
            )
        return statement
```

```python
# service.py — nunca construye queries; parte del statement readable
async def get_resource(auth_subject, resource_id):
    repo = ResourceRepository.from_session(session)
    statement = repo.get_readable_statement(auth_subject).where(
        Resource.id == resource_id
    )
    # Si no es accesible, retorna None (indistinguible de "no existe").
    # Indistinguibilidad evita filtrar información sobre la existencia del recurso.
    return await session.scalar(statement)
```

**Ventajas sobre verificación en controlador:**
- Imposible olvidar el check — la query misma está filtrada.
- Un solo punto de cambio si la regla de acceso evoluciona.
- IDOR eliminado por diseño — acceder a recurso ajeno retorna `None`.

**Reglas obligatorias:**

1. **NUNCA exponer queries directas al modelo desde servicios** — porque es
   donde se pierde el contexto de `auth_subject` y aparecen bugs IDOR; todo
   parte de `get_readable_statement`.
2. **`get_readable_statement` recibe exactamente un `auth_subject`; no existe
   overload "sin auth"** — porque un overload "admin sin filtro" se convierte
   en la puerta trasera que usa el 80% del código. Para bypass legítimo, crear
   un método explícito `get_statement_as_superuser()` auditable con grep.
3. **El filtro del repositorio NO sustituye la verificación de scope** — un
   token OAuth puede tener acceso a la organización pero no al scope
   `resources:write`; el scope se verifica en el endpoint, el filtro en el
   repositorio. Son capas complementarias.

**Refuerzo opcional con hook:** un PreToolUse que bloquee `select(Model)` en
archivos `service.py` fuerza el patrón. Ver `hooks/calidad-pre-commit.js` como
modelo de implementación.

Aplica a: FastAPI, Django (custom manager con `.for_user(user)`), Rails
(`scope :readable_by, ->(user) { ... }`), Laravel (Eloquent scopes globales
con `Model::booted`), cualquier ORM con query object pattern.

## Checklist de Seguridad Auth

- [ ] Passwords hasheados con bcrypt (rounds >= 10)
- [ ] JWT con expiración corta (access <= 30 min)
- [ ] Refresh tokens rotados en cada uso
- [ ] Refresh tokens almacenados en BD (para revocación)
- [ ] Rate limiting en endpoints de login y refresh
- [ ] HTTPS obligatorio en producción
- [ ] Headers de seguridad configurados
- [ ] IDOR verificado en TODOS los endpoints con recursos por ID
- [ ] RBAC en todos los endpoints de escritura
- [ ] Logs de auditoría en login, logout, cambio de contraseña
- [ ] Secrets en variables de entorno, nunca en código
- [ ] MFA disponible para roles privilegiados (ADMIN, NOMINA)
- [ ] Tokens no almacenados en localStorage (usar httpOnly cookies)

---

## IDOR defense: responder 404 en lugar de 403 cuando el recurso ajeno existe

### Regla

Cuando un usuario autenticado pero **no-dueño** intenta acceder a un recurso
ajeno identificable por ID (IDOR, Insecure Direct Object Reference):

- Responder **404 Not Found** — idéntico al caso donde el recurso no existe.
- **Nunca** responder 403 Forbidden distinguiendo "existe pero no puedes verlo".

### Por qué

`403` revela la existencia del recurso a terceros autenticados. Un atacante
puede enumerar IDs secuenciales (`/pedidos/1000`, `/pedidos/1001`, ...) y
distinguir:

- **404** → el recurso no existe
- **403** → el recurso existe pero es de otro usuario (fuga de información)

Con 404 uniforme, el atacante no puede mapear el universo de IDs existentes.
El dueño legítimo del recurso obtiene 200; todos los demás obtienen 404 con
el mismo body genérico.

### Patrón canónico (FastAPI)

```python
from fastapi import HTTPException, status, Depends

async def _verificar_ownership(
    recurso_id: int,
    user: Usuario = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
) -> Recurso:
    """Retorna el recurso solo si existe Y el user es su dueño.

    Caso no-dueño → 404 idéntico al caso de ID inexistente.
    """
    stmt = select(Recurso).where(
        Recurso.id == recurso_id,
        Recurso.usuario_id == user.id,   # filtro authoritativo server-side
    )
    recurso = (await db.execute(stmt)).scalar_one_or_none()

    if recurso is None:
        # No distinguir "no existe" de "no es tuyo"
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Recurso no encontrado",
        )
    return recurso


@router.get("/pedidos/{pedido_id}")
async def obtener_pedido(
    pedido: Pedido = Depends(_verificar_ownership),
):
    return pedido
```

### Anti-patrón

```python
# MAL — fuga de información
pedido = await db.get(Pedido, pedido_id)
if pedido is None:
    raise HTTPException(404)
if pedido.usuario_id != user.id:
    raise HTTPException(403, "No tienes permiso para ver este pedido")  # ← revela existencia
return pedido
```

### Excepciones controladas

- Si el recurso es **compartido por diseño** (archivo público, post de foro),
  el control de acceso no aplica y la respuesta puede ser 200 legítimamente.
- En admin panels donde el usuario **debe** saber qué IDs existen para
  moderar, el rol admin puede recibir 403 con detalle; usuarios normales 404.
- Logs del servidor deben distinguir ambos casos para detección de abuso
  (un mismo usuario acumulando 404s sobre IDs secuenciales es señal de
  enumeración).

### Frameworks de seguridad

- OWASP Top 10 A01:2021 — Broken Access Control.
- NIST CSF PR.AA-05 — Access rights and permissions are managed.
- MITRE ATT&CK T1613 — Container and Resource Discovery.

---

## Consent PII obligatorio en TODOS los ambientes (dev/staging/prod)

### Regla

Toda feature que procese datos personales identificables (PII) requiere consent
explícito del titular. El gate de consent **debe activarse en TODOS los
ambientes**, no solo en producción.

### Por qué

Las regulaciones de protección de datos (GDPR Art. 6, CCPA, LGPD Brasil,
LGPDPPSO México) **no distinguen entre ambientes**. Un bug en staging que
transfiere PII a un LLM externo sin consent es la misma violación que en
producción, con el agravante de que:

- Los datos de staging suelen ser "datos reales de hace 30 días" que siguen
  siendo PII.
- El pipeline de LLM en staging puede loggear prompts en el proveedor (OpenAI,
  Anthropic) que ya es "transferencia a terceros".
- El mismo bug detectado en staging se deploya a prod sin fix si el gate no
  se probó activo en staging.

### Anti-patrón más común

```python
# MAL — gate solo en production deja fugas en dev/staging
def requiere_consent_transferencia_pii(user: Usuario) -> bool:
    if settings.environment != "production":
        return False   # ← aquí está el agujero
    return not user.consent_transferencia_internacional
```

### Patrón canónico

```python
# BIEN — consent obligatorio en todos los ambientes
def requiere_consent_transferencia_pii(user: Usuario) -> bool:
    """Gate activo independiente del ambiente.

    Los ambientes dev/staging suelen tener datos clonados de producción
    o sintéticos-pero-realistas. Procesarlos sin consent viola la misma
    regulación que producción.
    """
    if not user.consent_transferencia_internacional:
        return True

    # Verificación adicional: consent no expirado
    if user.consent_fecha + CONSENT_VIGENCIA < datetime.now(UTC):
        return True

    return False


# En el resolver/factory del cliente LLM:
def resolver_llm_client(user: Usuario):
    if requiere_consent_transferencia_pii(user):
        # Usar solo LLM on-premise o rechazar la operación
        return _cliente_local_sin_egreso()
    return _cliente_cloud_con_consent_vigente()
```

### Checklist de consent

- [ ] El modelo `Usuario` tiene campo `consent_transferencia_internacional` con default `False`.
- [ ] El consent se registra con timestamp y versión del texto legal aceptado (auditable).
- [ ] El consent expira (típicamente 12-24 meses) y requiere re-confirmación.
- [ ] El gate se ejecuta en dev/staging/prod sin flag de bypass por ambiente.
- [ ] Hay test de integración que valida que un user sin consent recibe error en staging (no solo en prod).
- [ ] El log de decisión del gate incluye `user_id`, `decision`, `ambiente`, `motivo` para auditoría.
- [ ] Existe endpoint de revocación con efecto inmediato (invalida cualquier transferencia en curso).

### Frameworks de seguridad

- GDPR Art. 6 (bases de licitud), Art. 44-50 (transferencias internacionales).
- NIST CSF GV.OC-03 — Legal, regulatory, and contractual requirements regarding cybersecurity are understood and managed.
- NIST AI RMF GOVERN-6.1 — Policies and procedures are in place to address AI risks.
