Implementar SSO con SAML 2.0 (Python, Node.js, Java)
Implementa single sign-on SAML 2.0 como Service Provider: verificación de firma XML, metadatos IdP y sesiones seguras en Python, Node.js y Java.
Descripción General
SAML 2.0 es el protocolo dominante de single sign-on enterprise. Permite a una organización autenticar usuarios en un Identity Provider (IdP) centralizado — Okta, Azure AD, Keycloak o ADFS — y luego afirmar su identidad a Service Providers (SPs) downstream mediante documentos XML firmados. Implementar SAML correctamente requiere manejar parsing de XML de forma segura, validar firmas, gestionar intercambio de metadatos y prevenir ataques de replay.
Cuándo Usar
- Tu aplicación sirve clientes enterprise que ya tienen un IdP centralizado
- Necesitas identidad federada a través de múltiples organizaciones (SaaS multi-tenant)
- La organización requiere interoperabilidad a nivel de protocolo con infraestructura SSO existente
- Necesitas soportar Just-In-Time provisioning (crear cuentas desde assertions SAML)
- Requisitos de compliance mandatan un protocolo estándar en lugar de OAuth 2.0 / OIDC
Para una comparación más amplia de enfoques de autenticación, consulta la Guía Completa de Patrones de Autenticación.
Cuándo NO Usar
- Estás construyendo una aplicación orientada a consumidores — usa OAuth 2.0 + OIDC en su lugar
- El IdP soporta OIDC (la mayoría de IdPs modernos lo hacen) — OIDC es más simple, basado en JSON y más ampliamente soportado por librerías de developers
- Necesitas autenticación móvil o SPA — SAML es centrado en navegador web y incómodo para apps nativas
- El overhead de seguridad XML (verificación de firmas, validación de schema) excede la experiencia de tu equipo
Cómo funciona el flujo SAML SSO
La autenticación SAML es un handshake mediado por el navegador entre tres partes: el agente de usuario, el Service Provider (SP) y el Identity Provider (IdP). El SP nunca ve la contraseña del usuario — recibe una assertion XML firmada por el IdP que avala su identidad.
Tres campos concentran casi todo el peso de seguridad en la Response: la firma prueba que la assertion vino del IdP, InResponseTo la ata al AuthnRequest que emitiste (bloqueando el replay de una assertion capturada) y RelayState transporta la URL que el usuario quería originalmente.
Implementación Paso a Paso
Python (python3-saml / onelogin)
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.settings import OneLogin_Saml2_Settings
from flask import Flask, request, session, redirect, url_for
import os
# Configuración SAML (almacena certificados de forma segura, no en código)
SAML_CONFIG = {
"sp": {
"entityId": "https://app.example.com/saml/metadata",
"assertionConsumerService": {
"url": "https://app.example.com/saml/acs",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
},
"singleLogoutService": {
"url": "https://app.example.com/saml/sls",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
},
"x509cert": os.environ["SP_CERT"],
"privateKey": os.environ["SP_PRIVATE_KEY"]
},
"idp": {
"entityId": os.environ["IDP_ENTITY_ID"],
"singleSignOnService": {
"url": os.environ["IDP_SSO_URL"],
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
},
"x509cert": os.environ["IDP_CERT"]
},
"security": {
"nameIdEncrypted": False,
"authnRequestsSigned": True,
"logoutRequestSigned": True,
"wantAssertionsSigned": True,
"wantMessagesSigned": True,
"wantNameId": True,
"requestedAuthnContext": True,
"signMetadata": True
}
}
app = Flask(__name__)
app.secret_key = os.environ["SECRET_KEY"]
def init_saml_auth(req):
return OneLogin_Saml2_Auth(req, SAML_CONFIG)
@app.route("/saml/login")
def saml_login():
req = {
"https": "on" if request.is_secure else "off",
"http_host": request.host,
"script_name": request.path,
"server_port": request.environ.get("SERVER_PORT"),
"get_data": request.args.copy(),
"post_data": request.form.copy()
}
auth = init_saml_auth(req)
return redirect(auth.login())
@app.route("/saml/acs", methods=["POST"])
def saml_acs():
req = {
"https": "on" if request.is_secure else "off",
"http_host": request.host,
"script_name": request.path,
"server_port": request.environ.get("SERVER_PORT"),
"get_data": request.args.copy(),
"post_data": request.form.copy()
}
auth = init_saml_auth(req)
auth.process_response()
errors = auth.get_errors()
if errors:
app.logger.error(f"Errores SAML: {errors}")
return "Authentication failed", 401
if not auth.is_authenticated():
return "Not authenticated", 401
# Extraer atributos y crear sesión
session["saml_user"] = {
"email": auth.get_nameid(),
"attributes": auth.get_attributes(),
"session_index": auth.get_session_index()
}
# Opcional: JIT provisioning
user = find_or_create_user(
email=auth.get_nameid(),
name=auth.get_attributes().get("firstName", [""])[0],
groups=auth.get_attributes().get("groups", [])
)
session["user_id"] = user.id
return redirect("/dashboard")
@app.route("/saml/sls")
def saml_sls():
req = {
"https": "on" if request.is_secure else "off",
"http_host": request.host,
"script_name": request.path,
"server_port": request.environ.get("SERVER_PORT"),
"get_data": request.args.copy(),
"post_data": request.form.copy()
}
auth = init_saml_auth(req)
url = auth.process_slo(delete_session_cb=lambda: session.clear())
return redirect(url or "/")
Node.js (passport-saml)
import passport from 'passport';
import { Strategy as SamlStrategy } from 'passport-saml';
import fs from 'fs';
const samlConfig = {
entryPoint: process.env.IDP_SSO_URL,
issuer: 'https://app.example.com/saml/metadata',
callbackUrl: 'https://app.example.com/saml/acs',
logoutUrl: 'https://app.example.com/saml/sls',
cert: fs.readFileSync(process.env.IDP_CERT_PATH, 'utf-8'),
privateKey: fs.readFileSync(process.env.SP_PRIVATE_KEY_PATH, 'utf-8'),
decryptionPvk: fs.readFileSync(process.env.SP_PRIVATE_KEY_PATH, 'utf-8'),
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
validateInResponseTo: true,
disableRequestedAuthnContext: false,
acceptedClockSkewMs: 300000 // 5 minutos
};
passport.use(new SamlStrategy(samlConfig, (profile, done) => {
// JIT provisioning
const user = {
email: profile.nameID,
name: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || profile.nameID,
groups: profile['http://schemas.xmlsoap.org/claims/Group'] || [],
sessionIndex: profile.sessionIndex
};
return done(null, user);
}));
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
// Rutas Express
app.get('/saml/login', passport.authenticate('saml'));
app.post('/saml/acs',
passport.authenticate('saml', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
app.get('/saml/logout', (req, res) => {
samlStrategy.logout(req, (err, url) => {
if (err) return res.status(500).send('Logout failed');
req.logout(() => res.redirect(url));
});
});
Java (Spring Security SAML)
@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {
@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
RelyingPartyRegistration registration = RelyingPartyRegistrations
.fromMetadataLocation("https://idp.example.com/metadata.xml")
.registrationId("okta")
.entityId("https://app.example.com/saml/metadata")
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.signingX509Credentials(c -> c.add(
Saml2X509Credential.signing(loadPrivateKey(), loadCertificate())
))
.decryptionX509Credentials(c -> c.add(
Saml2X509Credential.decryption(loadPrivateKey(), loadCertificate())
))
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.saml2Login(saml2 -> saml2
.loginPage("/saml/login")
.defaultSuccessUrl("/dashboard", true)
)
.saml2Logout(withDefaults());
return http.build();
}
// Mapeo personalizado de usuario desde assertion SAML
@Bean
public GrantedAuthoritiesMapper authoritiesMapper() {
return authorities -> {
Set<GrantedAuthority> mapped = new HashSet<>();
for (GrantedAuthority auth : authorities) {
if (auth.getAuthority().startsWith("GROUP_")) {
mapped.add(new SimpleGrantedAuthority(
"ROLE_" + auth.getAuthority().substring(6)
));
}
}
return mapped;
};
}
}
Registrar el Service Provider
Antes de que funcione el primer login, el IdP necesita los metadatos de tu SP: el entityId, la URL del ACS, la URL del SLS y tu certificado de firma. La mayoría de librerías SAML generan este documento por ti. Con python3-saml, exponlo como un endpoint y pásale la URL al administrador del IdP:
@app.route("/saml/metadata")
def saml_metadata():
settings = OneLogin_Saml2_Settings(SAML_CONFIG, sp_validation_only=True)
metadata = settings.get_sp_metadata()
errors = settings.validate_metadata(metadata)
if errors:
return f"Metadatos SP inválidos: {errors}", 500
return metadata, 200, {"Content-Type": "application/samlmetadata+xml"}
Trata este archivo como un contrato: una vez que el IdP lo registra, cambiar el entityId o la URL del ACS rompe la integración. Lo mismo aplica a la inversa — cuando el IdP rota su certificado de firma, reimporta sus metadatos en lugar de fijar el certificado viejo.
No necesitas el tenant de Okta de un cliente para desarrollar. Levanta Keycloak en Docker (docker run -p 8080:8080 quay.io/keycloak/keycloak:26.0 start-dev, luego crea un cliente SAML en un realm) o usa una org gratuita de Okta Developer, y tendrás un IdP real para probar login, mapeo de atributos y SLO de punta a punta.
Dos detalles que conviene decidir temprano:
- RelayState para deep links. El valor opaco que envías con el AuthnRequest vuelve intacto en el POST a tu ACS. Úsalo para devolver a los usuarios a la página que pedían originalmente en lugar de mandar a todos a
/dashboard. - SSO iniciado por el IdP. Un IdP puede enviar una Response no solicitada sin
InResponseToque validar — lo que debilita la protección contra replay. Mantén las respuestas no solicitadas deshabilitadas (modostricten python3-saml, o rechazándolas en tu ACS) salvo que un cliente necesite específicamente un botón de “lanzamiento” desde el lado del IdP.
Cuando mapees los grupos entrantes del IdP a roles de la aplicación durante el JIT provisioning, la receta de RBAC muestra un modelo de roles limpio al que engancharse. Y como la private key del SP firma cada AuthnRequest, guárdala como recomienda la guía de gestión de secretos — un secret manager o HSM, nunca el repo.
Una versión ejecutable del SP en Flask — con el endpoint de metadatos, manejo de RelayState y un IdP Keycloak en docker-compose ya conectado — está en el repo complementario.
Lo que funciona
- Valida la firma de la respuesta SAML antes de parsear assertions. Los ataques XML Signature Wrapping (XSW) inyectan assertions maliciosas que evaden verificación de firma si el código busca en el nodo XML incorrecto.
- Usa HTTPS en todas partes. Los assertions SAML contienen tokens de autenticación; transmitirlos por HTTP permite interceptación y replay.
- Almacena private keys en un hardware security module (HSM) o secret manager. Nunca commitees SP private keys a git o las expongas en variables de entorno en máquinas de desarrolladores.
- Configura un lifetime corto para assertions (5-10 minutos). Los assertions son bearer tokens; uno robado puede ser reutilizado hasta que expire. El campo
NotOnOrAfterdebe ser validado estrictamente. - Implementa single logout (SLO). Sin SLO, hacer logout del SP no termina la sesión del IdP, permitiendo al usuario re-autenticarse silenciosamente vía otro SP.
Errores Comunes
- Desactivar la validación de firma en desarrollo y olvidar reactivarla. La vulnerabilidad SAML más común en producción es
wantAssertionsSigned: falseque queda del testing local. - Usar parsing de XML basado en strings en lugar de una librería XML segura. Los parsers DOM estándar son vulnerables a ataques XXE. Usa librerías SAML específicas que deshabiliten el procesamiento de DTD.
- Confiar en NameID como único identificador de usuario. Si un atacante puede cambiar su NameID en el IdP, puede suplantar a otro usuario. Mapea a un ID de usuario estable e interno.
- Ignorar clock skew. IdP y SP con relojes que difieren más que el lifetime del assertion causan logins legítimos fallidos. Permite un pequeño skew (1-5 minutos) pero loggea warnings.
- No validar el campo
InResponseTo. Sin este check, un atacante puede capturar un assertion legítimo y reutilizarlo contra una request de autenticación diferente.
Solución de Problemas
- La validación de firma falla de repente para todos los usuarios: el IdP probablemente rotó su certificado de firma. Reimporta los metadatos del IdP (o descárgalos periódicamente) en lugar de fijar el certificado viejo, y confirma que
wantAssertionsSignedsiga activo tras cualquier refactor. - Bucle de redirección entre
/saml/loginy/saml/acs: la cookie de sesión creada durante el login no sobrevive al POST cross-site que hace el IdP de vuelta. La cookie necesitaSameSite=None; Secure. Compara también la URL del ACS que llama el IdP con la registrada en su config — una barra final o un puerto distinto envían la Response a otro endpoint. - Los usuarios aterrizan en
/dashboarden vez de la página que pedían:RelayStatese está perdiendo. Verifica que el IdP lo devuelva literalmente; algunos IdPs lo eliminan en flujos iniciados por el IdP, así que mantén un destino por defecto como fallback. - El SLO cierra la sesión local pero el usuario se re-autentica en silencio: el SLO front-channel es best-effort — si el
SessionIndexdel LogoutRequest no coincide con el guardado en el login, el IdP puede ignorarlo. Guardaauth.get_session_index()en el ACS (los ejemplos de arriba lo hacen) y mantén sesiones locales cortas. - Errores de
NotBefore/NotOnOrAfterque aparecen y desaparecen: clock skew entre SP e IdP. Sincroniza ambos con NTP, permite una tolerancia pequeña (el ejemplo de Node usa 5 minutos) y alerta cuando el delta observado crece — un reloj del SP a la deriva acabará fallando cada login.
Puntos Clave
- Una assertion SAML es un bearer token: verifica la firma,
Audience,InResponseToyNotOnOrAfterantes de confiar en cualquier atributo que contenga. - El archivo de metadatos del SP es un contrato — una vez que el IdP lo registra, renombrar el
entityIdo mover la URL del ACS rompe la integración. - Depura contra un IdP real que controles (un contenedor de Keycloak o una org gratuita de Okta Developer). Perseguir bugs de firma XML a través del IdP de producción de un cliente es lento y doloroso.
- Prefiere SSO iniciado por el SP. Las respuestas iniciadas por el IdP no tienen
InResponseToque verificar, así que debilitan la protección contra replay — habilítalas solo cuando un cliente lo requiera.
Lecturas Adicionales
- OASIS SAML V2.0 Technical Overview — página del comité de las especificaciones SAML.
- OWASP SAML Security Cheat Sheet — ataques XSW, reglas de validación y checklist de hardening.
- Documentación de python3-saml, passport-saml y Spring Security SAML — la documentación de las librerías detrás de los tres ejemplos.
Preguntas frecuentes
¿Cuál es la diferencia entre SAML y OAuth 2.0?
SAML es un estándar basado en XML para autenticación, diseñado para SSO empresarial. OAuth 2.0 es un framework de autorización para acceso delegado a APIs; combínalo con OIDC cuando lo que necesitas es login.
¿Cuáles son los roles en SAML?
El Service Provider (SP) es la aplicación a la que el usuario quiere llegar. El Identity Provider (IdP) autentica al usuario y emite la assertion firmada que el SP valida.
¿Cómo aseguro las aserciones SAML?
Exige respuestas y assertions firmadas, valida InResponseTo, Audience y timestamps, transmite todo por HTTPS y guarda la private key del SP en un secret manager.
¿Puedo probar SAML sin acceso a un IdP enterprise real?
Sí. Ejecuta Keycloak en Docker y registra tu app como cliente SAML, o usa una org gratuita de Okta Developer. Ambos actúan como IdPs reales, así que pruebas el flujo completo de redirect — POST — sesión.
¿Qué diferencia hay entre SSO iniciado por el SP y por el IdP?
El iniciado por el SP arranca con un redirect que lleva un AuthnRequest, así que la respuesta puede atarse vía InResponseTo. El iniciado por el IdP envía una assertion no solicitada — cómodo para dashboards del IdP, pero no puede validarse contra una request.
¿Para qué sirve RelayState?
Es un valor opaco que el SP envía con el AuthnRequest y recibe de vuelta con la Response — la forma estándar de hacer deep-link a la página que el usuario pedía originalmente tras el login.
Recursos Relacionados
Implementar RBAC
Cómo implementar control de acceso basado en roles con roles jerárquicos, grants de permisos y middleware de enforce en Python, Node.js y Java.
RecipeImplementar ABAC
Cómo implementar control de acceso basado en atributos con motores de políticas, evaluación de contexto en vivo y decisiones de autorización granulares en Python, Node.js y Java.
GuideGestión de Secretos: Vault y Cloud Managers
Guía práctica de gestión de secretos: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault y GCP Secret Manager con rotación, control de acceso e integración CI/CD.
RecipeHash de Contraseñas con Argon2
Cómo hashear y verificar contraseñas de forma segura con Argon2id, ganador del Password Hashing Competition, con tuning correcto de parámetros y estrategias de migración desde bcrypt.
RecipeImplementar Gestión de Sesiones Segura
Cómo crear, validar y expirar sesiones de usuario de forma segura usando cookies HTTP-only, almacenamiento en Redis y protección CSRF.