Sign inSign up

vviudez/authservice

By vviudez

Updated 14 days ago

Image
0

644

vviudez/authservice repository overview

authservice — IdP SAML 2.0 y Proveedor OIDC/OAuth2 multi-tenant

Servicio que actúa como Identity Provider SAML 2.0 y, en paralelo, como Proveedor OIDC/OAuth2 (Authorization Code + PKCE), delegando la autenticación real en la API REST existente ("API de Autenticacion LDAP", http://localhost:8000/docs). Orquesta un flujo de login configurable, compartido por los dos protocolos:

credenciales → captcha (opcional) → filtrado de grupos → OTP (si el grupo lo exige) → Assertion SAML firmada / código de autorización OIDC

Es multi-tenant por FQDN: cada dominio por el que se accede al servicio es una identidad de IdP completamente independiente (propio entity_id/issuer, propio certificado SAML, propia clave OIDC, propios SP/clientes, propio backend LDAP, propias reglas). Todo lo anterior es configurable por tenant y, dentro de cada tenant, por SP/cliente OIDC, con reglas por defecto a nivel de tenant, en config/config.yaml (recarga en caliente, sin reiniciar).

Arquitectura en un vistazo

  • FastAPI + pysaml2 (núcleo SAML: metadata, parseo de AuthnRequest, firma de Response/Assertion) + PyJWT (núcleo OIDC: JWKS, firma de id_token/access_token, ver app/oidc/server.py) + httpx (cliente del backend LDAP).
  • Multi-tenant por FQDN (app/tenancy/registry.py): el Host de cada petición resuelve un tenant con su propio Saml2Server, su propio emisor OIDC y sus propios firmantes de cookie — nunca compartidos entre tenants.
  • El orquestador (app/flow/orchestrator.py) no conoce XML, JWT ni HTTP: es la pieza que decide "¿hace falta captcha? ¿grupo permitido? ¿hace falta OTP?" para SAML y OIDC por igual, y se testea como funciones puras.
  • Estado efímero del login (petición pendiente, sesión SSO, código de autorización OIDC) en SQLite, Postgres o MariaDB a elección (state_db.backend, de proceso — no por tenant —, ver app/flow/store.py); las cookies del navegador solo llevan un ID firmado (itsdangerous), nunca el contenido.
  • Config en YAML (app/config/), validada con pydantic, con hot-reload atómico: si la nueva config o los Server SAML/OIDC derivados de ella fallan al reconstruirse — de CUALQUIER tenant —, se descarta el cambio completo y se sigue sirviendo con la anterior.
  • Captcha conectable: Google reCAPTCHA (v2/v3) y Cloudflare Turnstile (app/captcha/), elegible por SP/cliente o por tenant.

Ver docs/DESIGN.md para el detalle de la secuencia SSO paso a paso, la resolución de tenant, la semántica de merge de reglas, el porqué de pysaml2/PyJWT y los trade-offs de SLO/OIDC.

Puesta en marcha rápida (Docker)

cp .env.example .env            # y rellenar secretos/claves de captcha
cp config/config.example.yaml config/config.yaml   # y ajustar id/fqdns/entity_id/base_url/SPs del tenant...

docker compose -f docker/docker-compose.yml up -d --build
curl http://localhost:8090/health
curl -H "Host: idp.miempresa.com" http://localhost:8090/saml/metadata

Si tu Docker no tiene el plugin docker compose (v2), usa el binario docker-compose (v1) con la misma sintaxis.

Si te olvidas del segundo cp (típico en un despliegue nuevo, sin config/config.yaml todavía en el host), el contenedor no se queda en crash-loop: siembra él mismo una copia de config.example.yaml y lo avisa bien visible en docker compose logs — pero esa copia trae dominios/tenant de ejemplo, así que edítala igualmente con los datos reales de tu despliegue y reinicia el contenedor.

/health no exige tenant (agrega el estado de todos), pero cualquier otra ruta sí resuelve por Host — con curl contra localhost:8090 hay que forzarlo con -H "Host: ..." (uno de los fqdns configurados); un navegador normal ya lo manda solo al visitar el dominio real.

La primera vez que arranca, si certs/idp-signing.{crt,key} no existen, se autogenera un certificado self-signed (ver idp.signing.autogenerate_if_missing en la config) — sustituirlo por uno propio antes de producción.

Publicar en Docker Hub para un servidor remoto (p.ej. ARM)

Si vas a construir aquí y publicar en Docker Hub para que OTRO servidor (que solo hace docker compose pull/up, nunca build) tire de la imagen, un docker build/docker push normal solo publica para la arquitectura de esta máquina — si ese servidor remoto es ARM y esta máquina es amd64 (o viceversa), el pull en el remoto falla con no matching manifest for linux/arm64/.... Para publicar una imagen multi-arquitectura (amd64 + arm64) de una sola vez:

docker login                      # una vez, con la cuenta vviudez
docker/build-and-push.sh v1.2.3   # tag obligatorio: publica v1.2.3 Y latest

El tag es obligatorio (no hay valor por defecto): identifica la versión publicada, además de latest, que se etiqueta y publica siempre en paralelo. Al terminar, el script hace un segundo build (reutilizando la caché del primero) solo para la arquitectura de esta máquina y lo carga en el almacén local de Docker, para que ambos tags aparezcan en docker image ls vviudez/authservice — la manifest list multi-arquitectura ya publicada en Docker Hub no es visible ahí (solo se ve completa desde el registro).

Ver los requisitos (QEMU, builder docker-container) en las cabeceras del propio script.

Desarrollo local sin Docker
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
# xmlsec1 debe estar instalado en el sistema (pysaml2 lo invoca via subprocess):
#   apt-get install xmlsec1   /   brew install libxmlsec1
uvicorn app.main:app --reload

Multi-tenant (tenants)

config/config.yaml es una lista de tenants (tenants: [...]), no una única identidad de IdP. Cada tenant es completo e independiente:

tenants:
  - id: "miempresa"              # slug estable, usado en paths de fichero (logo, certs)
    fqdns:
      - "idp.miempresa.com"      # Host de la peticion -> resuelve este tenant
    base_url: "https://idp.miempresa.com"   # identidad/sesion del tenant -- issuer OIDC y base de las URLs SAML
    session_secret: "..."        # firma las cookies idp_pending/idp_sso de ESTE tenant
    backend_api: { ... }         # backend LDAP propio (pueden ser servidores distintos por tenant)
    captcha: { ... }             # claves de captcha propias (site key registrado contra ESTE dominio)
    global_rules: { ... }
    attribute_mapping_defaults: { ... }
    saml: { enabled: true, ... }    # entity_id, certificado de firma propio, service_providers -- ver mas abajo
    oidc: { enabled: true, ... }    # ver seccion OIDC/OAuth2 mas abajo

Dentro de un tenant, saml y oidc son dos capacidades independientes, cada una con su propio enabled: true/false — un tenant puede tener solo SAML, solo OIDC, o los dos a la vez (al menos uno de los dos tiene que estar activado). Lo demás (base_url/session_secret/backend_api/captcha/ global_rules/attribute_mapping_defaults) es común a los dos protocolos: un tenant solo-OIDC también necesita base_url (es su issuer) y session_secret (firma las cookies del pipeline de login, compartido por los dos protocolos), así que esos campos no viven dentro de saml:.

Solo rate_limit y state_db viven a nivel de proceso, fuera de tenants (protegen/almacenan a nivel de proceso, no son parte de "quién es este IdP"). Un Host que no coincide con ningún fqdns de ningún tenant responde 404.

Añadir un tenant nuevo: agregar una entrada a tenants con su propio id/fqdns/certificado — el servicio lo recoge solo (hot-reload). Si algún tenant queda mal configurado (p.ej. certificado inaccesible, o dos tenants compartiendo el mismo fichero de firma), la recarga completa se rechaza, protegiendo también a los tenants que sí estaban bien.

Configurar el flujo por SP/cliente OIDC

Ver config/config.example.yaml, comentado. Resumen de la semántica de merge (importante): en las rules de un SP o cliente OIDC, una clave ausente hereda de global_rules de su tenant; una clave presente (aunque sea [] o {enabled: false}) reemplaza por completo el valor global para ese SP/cliente.

Añadir un SP nuevo: agregar una entrada en saml.service_providers (dentro del tenant) con su entity_id, acs_urls y, opcionalmente, rules/ attribute_mapping propios — el servicio recoge el cambio solo (hot-reload), sin reiniciar.

OIDC/OAuth2 (oidc)

Cada tenant puede exponer, además de (o en vez de) SAML, un Proveedor OIDC/OAuth2 — basta con añadir el bloque oidc: a ese tenant con enabled: true:

tenants:
  - id: "miempresa"
    # ...
    oidc:
      enabled: true
      signing:
        private_key_file: "certs/miempresa/oidc-signing.pem"   # clave RSA DEDICADA, nunca el certificado SAML
      clients:
        - client_id: "mi-app"
          redirect_uris:
            - "https://mi-app.miempresa.com/callback"
          # scopes por defecto: openid, profile, email, groups

Un tenant con oidc.enabled: false (o sin bloque oidc: — es equivalente, false es el valor por defecto) no expone ningún endpoint /oidc/* ni el discovery document (404). Alcance deliberadamente acotado en esta primera versión:

  • Solo Authorization Code + PKCE (S256 obligatorio) — sin client_secret ni clientes confidenciales: todo cliente es "público", autenticado únicamente por PKCE + redirect_uri exacta registrada en redirect_uris.
  • Sin Client Credentials (flujo máquina-a-máquina) ni refresh tokens.
  • Reutiliza el mismo pipeline credenciales→captcha→grupos→OTP que SAML — rules/attribute_mapping (aquí sin name_format, que es puramente SAML) se configuran igual que en un SP, dentro de cada cliente.
  • Comparte sesión SSO con SAML: autenticarse en un SP SAML y visitar después un cliente OIDC del mismo tenant no vuelve a pedir credenciales (y viceversa) — recalculando siempre grupos/OTP para el SP/cliente concreto.

Endpoints (issuer = base_url del tenant):

EndpointRol
GET /.well-known/openid-configurationDiscovery document
GET /oidc/jwks.jsonClaves públicas (verificar la firma del id_token)
GET/POST /oidc/authorizeInicio del login (mismo formulario que SAML)
POST /oidc/tokenCanjea el code (+ PKCE) por id_token/access_token
GET /oidc/userinfoClaims de perfil, filtrados por el scope concedido

Ver dummy_oidc_rp/ (cliente mínimo, no productivo) para un ejemplo funcional completo, y docs/DESIGN.md para el detalle de diseño (límite anti-open-redirect, mapeo de errores SAML↔OAuth2, separación de audiencia id_token/access_token).

Almacén de estado (state_db)

pending_logins (login en curso), sso_sessions (sesión ya completada) y oidc_auth_codes (código de autorización OIDC pendiente de canjear) viven en una base de datos elegida por state_db.backend — de proceso, compartida por todos los tenants (cada fila lleva su propio tenant_id, comprobado siempre antes de confiar en una cookie):

backendCuándo usarloRequiere
sqlite (por defecto)Una sola instancia del servicioNada — fichero local (data/flow_state.db)
postgresVarias réplicas compartiendo estadoUn servidor Postgres accesible
mariadbVarias réplicas compartiendo estadoUn servidor MariaDB/MySQL accesible

Ver la sintaxis completa de cada backend en config/config.example.yaml. Cambiar de backend exige reiniciar el proceso — a diferencia de casi todo lo demás en config/config.yaml, no se recoge en caliente.

Para probar postgres/mariadb en local sin un servidor propio:

docker compose -f docker/docker-compose.yml --profile postgres up -d   # o --profile mariadb

(datos desechables, no persisten entre down — solo para probar).

Rate limiting (rate_limit)

Limita el tráfico HTTP a todas las rutas del servicio (no solo /saml/*), antes de cualquier lógica de negocio — protege tanto contra una IP abusiva como contra la suma de tráfico de muchos clientes a la vez:

rate_limit:
  enabled: false          # activarlo no requiere reiniciar (se recoge en caliente)
  per_ip_per_second: 5    # máximo por IP de origen; null = sin límite por IP
  global_per_second: 50   # máximo total, sumando todas las IP; null = sin límite global

Desactivado por defecto. Al superarlo, responde 429 Too Many Requests. La IP usada es la ya resuelta por uvicorn a partir de X-Forwarded-For si hay un proxy de confianza delante (ver --forwarded-allow-ips en docker/Dockerfile) — sin eso, todo el tráfico detrás de un reverse proxy compartiría la misma IP (la del proxy) y per_ip_per_second limitaría por error el conjunto.

Es en memoria y por proceso: con varias réplicas detrás de un balanceador, cada una aplica sus límites de forma independiente (el límite "global" real observado sería global_per_second × número de réplicas). Ver docs/DESIGN.md.

Personalización: logo y tema

El logo de las páginas (login, verificación OTP, error...) se sirve desde data/tenants/<id>/logo.png (propio de ese tenant) → data/logo.png (logo global, aplica a cualquier tenant sin uno propio) → genérico empaquetado, en ese orden. Sustituir el fichero se refleja al momento, sin reiniciar el servicio ni reconstruir la imagen Docker.

El tema es claro/oscuro a elección de quien usa la web (botón en la cabecera, persistido en localStorage), por defecto oscuro.

Captcha

El campo provider de captcha en la config (global_rules.captcha.provider de un tenant, o rules.captcha.provider de un SP/cliente OIDC) admite tres valores; cada uno es en realidad el proveedor externo indicado aquí, con su propia consola donde dar de alta el sitio y obtener las claves que van en .env:

providerServicioAlta / consola (crear "sitio" y obtener claves)Claves en .env
recaptchaGoogle reCAPTCHA (v2 checkbox o v3 basado en score)https://www.google.com/recaptcha/adminRECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY
turnstileCloudflare Turnstilehttps://dash.cloudflare.com/?to=/:account/turnstileTURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY
noneSin captcha

Al dar de alta el sitio en cualquiera de las dos consolas hay que registrar el/los dominio(s) exactos donde se va a mostrar el widget — el dominio del propio IdP (base_url del tenant), no el de los Service Providers. Un site key creado para un dominio distinto del que sirve /login falla en el navegador aunque la clave esté bien copiada en .env.

Solo hace falta rellenar en .env el proveedor que realmente se use en global_rules o en las rules de algún SP/cliente OIDC de ese tenant — si el otro proveedor sigue presente en captcha: pero ninguna regla activa lo referencia, que falte su variable de entorno no impide arrancar (se descarta ese bloque en silencio, con un aviso en el log); solo se exige de verdad la variable del proveedor que efectivamente esté en uso. Ver config/config.example.yaml para la sintaxis completa (version/min_score/ expected_action en reCAPTCHA v3).

Auditoría de login (logger authservice.audit)

El log de acceso de uvicorn ("POST /login HTTP/1.1" 200 OK) nunca lleva el usuario. Cada intento de login (SAML u OIDC) queda además trazado en un logger dedicado, authservice.audit, con el usuario y el resultado en cada punto del pipeline — pensado para responder incidencias sin tener que correlar peticiones por IP/timestamp:

2026-08-19 13:50:27,432 INFO authservice.audit: login outcome=credentials_ok usuario=jperez tenant=guifibaix participant=... protocolo=saml
2026-08-19 13:50:32,191 INFO authservice.audit: login outcome=ok usuario=jperez tenant=guifibaix participant=... protocolo=saml
2026-08-19 13:50:27,429 WARNING authservice.audit: login outcome=InvalidCredentialsFlowError usuario=jperez tenant=guifibaix participant=... protocolo=saml detalle="Usuario o contrasena incorrectos."
2026-08-19 13:50:27,429 WARNING authservice.audit: login outcome=GroupDeniedFlowError usuario=jperez tenant=guifibaix participant=... protocolo=saml detalle="..."
2026-08-19 13:50:27,429 WARNING authservice.audit: login outcome=OtpInvalidFlowError usuario=jperez tenant=guifibaix participant=... protocolo=saml detalle="... (intento 2/5)"

outcome=ok es el único éxito definitivo; cualquier otro valor es el nombre de la FlowError que causó el fallo (app/flow/errors.py) — así el log nunca se desincroniza a mano del código real que decide el resultado. Nunca incluye contraseña ni tokens, solo FlowError.user_message (ya pensado para mostrarse). Para filtrar solo estas líneas en el log combinado del contenedor: docker logs authservice 2>&1 | grep 'authservice.audit' (o el equivalente en tu agregador de logs, si usas driver: syslog como en docker/docker-compose.yml).

Limitaciones conocidas

  • SLO simplificado: termina la sesión local del IdP e invoca el logoff del backend, pero no construye/envía LogoutResponse SAML de vuelta al SP (exigiría registrar su endpoint de SLO) ni propaga el logout en cadena a otros SP visitados en la misma sesión SSO.
  • El alta/gestión de semillas TOTP (/totp/seeds* del backend) se asume responsabilidad de un portal de perfil de usuario fuera de este IdP; este servicio solo consume /totp/verify.
  • El happy path completo (login real → grupos reales → Assertion firmada → SP verificando la firma y extrayendo atributos) se validó end-to-end contra el backend LDAP real, además de los caminos de error (503, 401) y, con el backend mockeado, el pipeline HTTP+SAML completo de grupo denegado/OTP.
  • OIDC sin client_secret/Client Credentials/refresh tokens: alcance deliberado de esta primera versión (solo Authorization Code + PKCE, ver sección OIDC/OAuth2). Ampliable más adelante sin romper lo ya soportado.
  • Sin rotación de clave OIDC con solapamiento: un solo kid activo por tenant; rotar la clave (o su fichero) invalida cualquier token no expirado todavía — el TTL corto por defecto (minutos) acota el impacto.
  • rate_limit/state_db son de proceso, no por tenant: con varias réplicas, o varios tenants de tráfico muy dispar, un tenant ruidoso consume presupuesto del límite compartido por todos — mismo trade-off ya aceptado para el rate limiting en general (ver sección correspondiente).

Migrar una config de una versión anterior

Si tu config/config.yaml es de antes de que este servicio fuera multi-tenant (un único bloque idp:/backend_api:/service_providers: a nivel raíz, sin tenants:), envuelve todo ese contenido en una entrada de tenants: con un id/fqdns nuevos, y mueve state_db (si estaba bajo idp:) al nivel raíz — ver la forma completa en config/config.example.yaml. El comportamiento runtime es idéntico; solo cambia la forma del YAML.

Si tu config ya es multi-tenant pero de antes de que saml/oidc fueran bloques independientes con enabled (un único bloque idp: a nivel de tenant, con service_providers: como hermano suyo): mueve entity_id/ sso_path/slo_path/metadata_path/signing/sign_assertions/ sign_responses y service_providers: dentro de un nuevo bloque saml: con enabled: true; mueve base_url/session_secret/session/ password_reset_url (antes bajo idp:) directamente al nivel del tenant, como hermanos de backend_api/captcha; y, si ya tenías oidc:, añádele enabled: true y muévele oidc_claims_mapping_defaults (antes hermano de oidc: a nivel de tenant) dentro de oidc: como claims_mapping_defaults. De nuevo, el comportamiento runtime es idéntico.

Tag summary

Content type

Image

Digest

sha256:068333f37

Size

84.6 MB

Last updated

14 days ago

docker pull vviudez/authservice