Skip to content

iris.api — Configuración Fase 3

FastAPI · joserfc · Keycloak OIDC · app.iris-lib.click · Puerto 8001 (dev)


1. Dependencias

Agregadas como optional group api en pyproject.toml:

uv add --optional api fastapi uvicorn authlib httpx python-multipart pydantic-settings

authlib se instaló pero se migró a joserfc (ya incluido como dependencia de authlib) para evitar el deprecation warning. authlib puede removerse en una limpieza futura.


2. Estructura

src/iris/applications/api/
├── __init__.py
├── config.py          # Settings via pydantic-settings
├── dependencies.py    # Validación JWT, get_current_user, require_role, require_org
├── main.py            # FastAPI app, middlewares, routers
└── routers/
    ├── __init__.py
    ├── auth.py        # POST /auth/token, GET /auth/userinfo
    ├── users.py       # GET /users/me, GET /users/, POST /users/
    └── genomics.py    # (Fase 4)

3. Configuración — config.py

Lee variables de entorno con prefijo IRIS_ desde .env:

class Settings(BaseSettings):
    keycloak_url: str           # IRIS_KEYCLOAK_URL
    keycloak_realm: str         # IRIS_KEYCLOAK_REALM
    keycloak_client_id: str     # IRIS_KEYCLOAK_CLIENT_ID
    keycloak_client_secret: str # IRIS_KEYCLOAK_CLIENT_SECRET
    jwt_algorithms: list[str]   # IRIS_JWT_ALGORITHMS (default: ["RS256"])
    jwt_audience: str           # IRIS_JWT_AUDIENCE (default: "iris-api")
    postgrest_url: str          # IRIS_POSTGREST_URL
    api_title: str              # IRIS_API_TITLE
    api_version: str            # IRIS_API_VERSION
    debug: bool                 # IRIS_DEBUG

Propiedades derivadas:

settings.keycloak_base_url  # https://auth.iris-lib.click/realms/iris
settings.jwks_uri           # .../protocol/openid-connect/certs
settings.token_endpoint     # .../protocol/openid-connect/token
settings.userinfo_endpoint  # .../protocol/openid-connect/userinfo
settings.admin_base_url     # https://auth.iris-lib.click/admin/realms/iris

Importante: usar str en lugar de AnyHttpUrl de Pydantic para las URLs. AnyHttpUrl agrega trailing slash causando doble // en las URLs construidas. El validator strip_trailing_slash limpia cualquier slash final.

Archivo .env:

IRIS_KEYCLOAK_URL=https://auth.iris-lib.click
IRIS_KEYCLOAK_REALM=iris
IRIS_KEYCLOAK_CLIENT_ID=iris-api
IRIS_KEYCLOAK_CLIENT_SECRET=<secret>
IRIS_JWT_AUDIENCE=iris-api
IRIS_POSTGREST_URL=https://api.iris-lib.click
IRIS_DEBUG=true

4. Validación JWT — dependencies.py

Usa joserfc para validar tokens RS256 emitidos por Keycloak.

Flujo de validación

Request con Bearer token
  → get_current_user()
    → get_jwks()         # descarga JWKS de Keycloak
    → jwt.decode()       # valida firma RS256
    → claims.validate()  # valida iss, aud, exp con leeway=60s
    → retorna claims dict

Funciones disponibles

# Inyectable via Depends — devuelve claims del JWT
CurrentUser = Annotated[dict, Depends(get_current_user)]

# Decorator de protección por rol
require_role("iris_admin")
require_role("bioinformatician", "iris_admin")  # múltiples roles permitidos

# Decorator de protección por org
require_org(org_id=7)  # verifica org_id del token, all_orgs=true lo bypasea

Uso en routers

from iris.applications.api.dependencies import CurrentUser, require_role

# Proteger endpoint por rol
@router.get(
    "/sensitive",
    dependencies=[Depends(require_role("iris_admin"))],
)
async def sensitive_endpoint() -> dict: ...

# Acceder a claims del usuario actual
@router.get("/me")
async def me(current_user: CurrentUser) -> dict:
    return {"role": current_user.get("role")}

leeway=60 — 60 segundos de tolerancia de clock skew en la validación de iat y exp.


5. Routers

routers/auth.py/auth

Método Endpoint Auth Descripción
POST /auth/token No Proxy al token endpoint de Keycloak
GET /auth/userinfo Bearer Devuelve claims del JWT actual

POST /auth/token abstrae la URL de Keycloak — los clients usan iris.api como único punto de entrada y no necesitan conocer la URL del realm.

routers/users.py/users

Método Endpoint Rol requerido Descripción
GET /users/me Cualquiera Perfil del usuario autenticado
GET /users/ iris_admin Lista todos los usuarios del realm
POST /users/ iris_admin Crea usuario en Keycloak

POST /users/ — creación de usuario

{
  "username": "dr-garcia",
  "email": "garcia@hospital.com",
  "first_name": "Juan",
  "last_name": "García",
  "role": "org_member",
  "org_id": 7,
  "sub_role": "clinician"
}

Internamente: 1. Crea el usuario en Keycloak via Admin API 2. Asigna el realm role correspondiente 3. Asigna al grupo correcto según rol:

role org_id sub_role Grupo asignado
org_member 7 clinician /orgs/7/clinician
bioinformatician /iris-internal/bioinformatician
employee /iris-internal/employee
patient /patients

6. main.py

app = FastAPI(title=settings.api_title, ...)

app.add_middleware(CORSMiddleware, allow_origins=["*"], ...)

app.include_router(auth.router)    # /auth
app.include_router(users.router)   # /users
# app.include_router(genomics.router)  # /genomics — Fase 4

@app.get("/health")
async def health() -> dict:
    return {"status": "ok", "version": settings.api_version}

7. Arranque

Desarrollo

uv run uvicorn iris.applications.api.main:app --reload --port 8001

Producción (systemd + Podman)

Pendiente — se configura al final de la Fase 5 cuando todas las apps estén listas.


8. Verificación end-to-end

Obtener token via proxy

# Paso 1 — device code (directo a Keycloak, solo para obtener el code)
curl -s -X POST \
  https://auth.iris-lib.click/realms/iris/protocol/openid-connect/auth/device \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=iris-genomics-cli" \
  -d "client_secret=<secret>" \
  -d "scope=openid"

# Autenticar en el navegador: verification_uri_complete

# Paso 2 — token via iris.api (no directo a Keycloak)
TOKEN=$(curl -s -X POST http://localhost:8001/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
  -d "device_code=<device_code>" \
  -d "client_id=iris-genomics-cli" \
  -d "client_secret=<secret>" \
  | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])")

Endpoints de prueba

# Health
curl -s http://localhost:8001/health | python3 -m json.tool

# Userinfo (valida JWT completo)
curl -s http://localhost:8001/auth/userinfo \
  -H "Authorization: Bearer $TOKEN" \
  | python3 -m json.tool

# Perfil del usuario
curl -s http://localhost:8001/users/me \
  -H "Authorization: Bearer $TOKEN" \
  | python3 -m json.tool

Resultado validado — GET /users/me

{
    "sub": "c1cda5e1-4df8-4ab6-91cc-78e4bab7e304",
    "username": "test-bio",
    "email": "test-bio@iris.local",
    "name": "Test Bio",
    "role": "org_member",
    "sub_role": "clinician",
    "org_id": 1,
    "all_orgs": null,
    "patient_id": null
}

9. Notas importantes

JWKS caching — actualmente get_jwks() hace un request HTTP en cada validación de token. En producción conviene cachear el JWKS con TTL de ~5 minutos para reducir latencia. Implementar con cachetools o un simple dict con timestamp en lifespan.

Admin API tokenget_admin_token() en users.py usa client_credentials con iris-api como service account. Requiere que iris-api tenga Service accounts roles: ON en Keycloak y el role realm-management/manage-users asignado al service account.

CORS — configurado con allow_origins=["*"] para desarrollo. En producción restringir a los dominios de las apps:

allow_origins=[
    "https://genomics.iris-lib.click",
    "https://erm.iris-lib.click",
    "https://crm.iris-lib.click",
]

Puerto — en desarrollo corre en 8001 porque 8000 estaba ocupado. En producción se expone via Caddy en app.iris-lib.click.