StackPractices
advanced Por Mathias Paulenko

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.

sequenceDiagram diagram: actor Usuario

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 InResponseTo que validar — lo que debilita la protección contra replay. Mantén las respuestas no solicitadas deshabilitadas (modo strict en 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 NotOnOrAfter debe 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: false que 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 wantAssertionsSigned siga activo tras cualquier refactor.
  • Bucle de redirección entre /saml/login y /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 necesita SameSite=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 /dashboard en vez de la página que pedían: RelayState se 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 SessionIndex del LogoutRequest no coincide con el guardado en el login, el IdP puede ignorarlo. Guarda auth.get_session_index() en el ACS (los ejemplos de arriba lo hacen) y mantén sesiones locales cortas.
  • Errores de NotBefore/NotOnOrAfter que 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, InResponseTo y NotOnOrAfter antes 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 entityId o 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 InResponseTo que verificar, así que debilitan la protección contra replay — habilítalas solo cuando un cliente lo requiera.

Lecturas Adicionales

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.