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).
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).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.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.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.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.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.
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 binariodocker-compose(v1) con la misma sintaxis.
Si te olvidas del segundo
cp(típico en un despliegue nuevo, sinconfig/config.yamltodavía en el host), el contenedor no se queda en crash-loop: siembra él mismo una copia deconfig.example.yamly lo avisa bien visible endocker 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.
/healthno exige tenant (agrega el estado de todos), pero cualquier otra ruta sí resuelve porHost— concurlcontralocalhost:8090hay que forzarlo con-H "Host: ..."(uno de losfqdnsconfigurados); 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.
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.
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
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.
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)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:
S256 obligatorio) — sin client_secret
ni clientes confidenciales: todo cliente es "público", autenticado
únicamente por PKCE + redirect_uri exacta registrada en redirect_uris.rules/attribute_mapping (aquí sin name_format, que es puramente SAML)
se configuran igual que en un SP, dentro de cada cliente.Endpoints (issuer = base_url del tenant):
| Endpoint | Rol |
|---|---|
GET /.well-known/openid-configuration | Discovery document |
GET /oidc/jwks.json | Claves públicas (verificar la firma del id_token) |
GET/POST /oidc/authorize | Inicio del login (mismo formulario que SAML) |
POST /oidc/token | Canjea el code (+ PKCE) por id_token/access_token |
GET /oidc/userinfo | Claims 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).
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):
backend | Cuándo usarlo | Requiere |
|---|---|---|
sqlite (por defecto) | Una sola instancia del servicio | Nada — fichero local (data/flow_state.db) |
postgres | Varias réplicas compartiendo estado | Un servidor Postgres accesible |
mariadb | Varias réplicas compartiendo estado | Un 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_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.
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.
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:
provider | Servicio | Alta / consola (crear "sitio" y obtener claves) | Claves en .env |
|---|---|---|---|
recaptcha | Google reCAPTCHA (v2 checkbox o v3 basado en score) | https://www.google.com/recaptcha/admin | RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY |
turnstile | Cloudflare Turnstile | https://dash.cloudflare.com/?to=/:account/turnstile | TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY |
none | Sin 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).
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).
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./totp/seeds* del backend) se asume
responsabilidad de un portal de perfil de usuario fuera de este IdP; este
servicio solo consume /totp/verify.503, 401) y, con
el backend mockeado, el pipeline HTTP+SAML completo de grupo denegado/OTP.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.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).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.
Content type
Image
Digest
sha256:068333f37…
Size
84.6 MB
Last updated
14 days ago
docker pull vviudez/authservice