DEV Community

Cover image for SAML SSO Identity Provider in Node.js: The Complete Guide (Part 1)
Mudaser Ali
Mudaser Ali

Posted on

SAML SSO Identity Provider in Node.js: The Complete Guide (Part 1)

SAML SSO Identity Provider in Node.js: The Complete Guide (Part 1)

Building Enterprise Authentication in 5 Minutes

By Syed Mudaser Ali Kazmi (SMAK)


When enterprise clients ask for SAML SSO integration, most developers' hearts skip a beat. The complexity of XML parsing, certificate management, and SAML protocol intricacies can turn a simple authentication request into weeks of development hell.

But what if I told you that you could build a production-ready SAML Identity Provider (IdP) in Node.js in just 5 minutes?

What is a SAML Identity Provider?

A SAML Identity Provider is the authentication server that:

  • πŸ” Authenticates users (validates credentials)
  • 🎫 Issues SAML assertions (digital tokens)
  • πŸ”’ Signs and encrypts authentication responses
  • πŸ“‹ Provides metadata for Service Providers to trust

Think of it as the "bouncer" of the digital world - it verifies who you are and gives you a VIP pass (SAML assertion) to access other applications.

Why Build Your Own IdP?

Enterprise Scenarios:

  • Custom Authentication Logic: Integrate with proprietary user stores
  • Multi-Factor Authentication: Add custom MFA flows
  • Compliance Requirements: Meet specific security standards
  • Cost Optimization: Avoid expensive third-party IdP licensing
  • Full Control: Complete customization of user experience

Modern Use Cases:

  • B2B SaaS: Provide SSO for enterprise customers
  • Internal Tools: Centralize authentication for multiple apps
  • Partner Integration: Enable federated access for partners
  • Microservices: Create authentication service for service mesh

The Traditional SAML Pain Points

Before we solve them, let's acknowledge what makes SAML development challenging:

// Traditional SAML implementation nightmare
const xml2js = require('xml2js');
const crypto = require('crypto');
const zlib = require('zlib');

// 200+ lines of XML template generation
const generateSAMLResponse = (user, requestId) => {
  // Complex XML parsing and generation
  // Certificate loading and validation
  // Signature generation
  // Encryption handling
  // Base64 encoding/decoding
  // Deflate compression
  // ... weeks of debugging
};
Enter fullscreen mode Exit fullscreen mode

Problems with this approach:

  • ❌ Hundreds of lines of boilerplate code
  • ❌ Easy to introduce security vulnerabilities
  • ❌ Difficult to test and debug
  • ❌ Poor maintainability
  • ❌ XML template hell

Enter saml-sso-helper: The Game Changer

I built saml-sso-helper to eliminate all this complexity:

// Modern SAML implementation
const SAMLHelper = require('saml-sso-helper');

const samlHelper = new SAMLHelper({
    encryption: true,
    entityID: 'https://your-company.com/idp',
    baseURL: 'https://your-company.com',
    certificates: { /* your certs */ }
});

samlHelper.createIdentityProvider();
// Done! πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

5-Minute IdP Implementation

Let's build a complete SAML Identity Provider from scratch:

Step 1: Project Setup (30 seconds)

mkdir my-saml-idp && cd my-saml-idp
npm init -y
npm install saml-sso-helper express express-session bcryptjs
Enter fullscreen mode Exit fullscreen mode

Step 2: Generate Certificates (1 minute)

mkdir certificates && cd certificates

# Generate signing certificate
openssl req -x509 -newkey rsa:2048 -keyout idp-signing.key \
  -out idp-signing.cert -days 365 -nodes \
  -subj "/CN=your-company.com/O=Your Company/C=US"

# Generate encryption certificate  
openssl req -x509 -newkey rsa:2048 -keyout idp-encrypt.key \
  -out idp-encrypt.cert -days 365 -nodes \
  -subj "/CN=your-company.com/O=Your Company/C=US"

cd ..
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the Identity Provider (3.5 minutes)

Create idp.js:

const express = require('express');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const path = require('path');
const SAMLHelper = require('saml-sso-helper');

const app = express();

// Middleware setup
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(session({
    secret: 'your-session-secret-change-in-production',
    resave: false,
    saveUninitialized: false,
    cookie: { secure: false } // Set to true in production with HTTPS
}));

// Mock user database (replace with your actual user store)
const users = [
    {
        id: 1,
        email: 'john.doe@company.com',
        password: bcrypt.hashSync('password123', 10),
        profile: {
            displayName: 'John Doe',
            firstName: 'John',
            lastName: 'Doe',
            department: 'Engineering',
            role: 'Senior Developer',
            employeeId: 'EMP001'
        }
    },
    {
        id: 2,
        email: 'jane.smith@company.com',
        password: bcrypt.hashSync('secure456', 10),
        profile: {
            displayName: 'Jane Smith',
            firstName: 'Jane',
            lastName: 'Smith',
            department: 'Product',
            role: 'Product Manager',
            employeeId: 'EMP002'
        }
    }
];

// Initialize SAML Helper
const samlHelper = new SAMLHelper({
    encryption: true,
    entityID: 'https://your-company.com/idp/metadata',
    baseURL: 'https://your-company.com',
    partnerMetadataURL: 'https://partner-app.com/sp/metadata',
    certificates: {
        signing: {
            key: path.join(__dirname, 'certificates/idp-signing.key'),
            cert: path.join(__dirname, 'certificates/idp-signing.cert')
        },
        encryption: {
            key: path.join(__dirname, 'certificates/idp-encrypt.key'),
            cert: path.join(__dirname, 'certificates/idp-encrypt.cert')
        }
    },
    attributes: [
        'email',
        'displayName',
        'firstName',
        'lastName',
        'department',
        'role',
        'employeeId'
    ]
});

// Create IdP instance
samlHelper.createIdentityProvider();
const middleware = samlHelper.getExpressMiddleware();

// Helper functions
const findUserByEmail = (email) => users.find(u => u.email === email);
const authenticateUser = async (email, password) => {
    const user = findUserByEmail(email);
    if (!user) return null;

    const isValid = await bcrypt.compare(password, user.password);
    return isValid ? user : null;
};

// Routes

// IdP Metadata endpoint
app.get('/metadata', middleware.idp.metadata);

// Login page
app.get('/login', (req, res) => {
    const { SAMLRequest, RelayState } = req.query;

    res.send(`
        <!DOCTYPE html>
        <html>
        <head>
            <title>Company Login - SAML IdP</title>
            <style>
                body { font-family: Arial, sans-serif; max-width: 400px; margin: 100px auto; padding: 20px; }
                .login-form { background: #f5f5f5; padding: 30px; border-radius: 8px; }
                input { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px; }
                button { background: #007bff; color: white; padding: 12px 20px; border: none; border-radius: 4px; cursor: pointer; width: 100%; }
                button:hover { background: #0056b3; }
                .error { color: red; margin: 10px 0; }
            </style>
        </head>
        <body>
            <div class="login-form">
                <h2>πŸ” Company Portal Login</h2>
                <form method="POST" action="/login">
                    <input type="hidden" name="SAMLRequest" value="${SAMLRequest || ''}" />
                    <input type="hidden" name="RelayState" value="${RelayState || ''}" />

                    <input type="email" name="email" placeholder="Email Address" required />
                    <input type="password" name="password" placeholder="Password" required />

                    <button type="submit">Sign In</button>
                </form>

                <div style="margin-top: 20px; font-size: 12px; color: #666;">
                    <strong>Demo Accounts:</strong><br>
                    john.doe@company.com / password123<br>
                    jane.smith@company.com / secure456
                </div>
            </div>
        </body>
        </html>
    `);
});

// Login processing
app.post('/login', async (req, res) => {
    const { email, password, SAMLRequest, RelayState } = req.body;

    try {
        // Authenticate user
        const user = await authenticateUser(email, password);

        if (!user) {
            return res.status(401).send(`
                <h2>Login Failed</h2>
                <p>Invalid email or password.</p>
                <a href="/login?SAMLRequest=${SAMLRequest}&RelayState=${RelayState}">Try Again</a>
            `);
        }

        // Store user session
        req.session.user = user;

        // If this is a SAML request, continue to SSO
        if (SAMLRequest) {
            req.query.SAMLRequest = SAMLRequest;
            req.query.RelayState = RelayState;
            return app._router.handle({...req, method: 'GET', url: '/sso'}, res);
        }

        // Regular login success
        res.send(`
            <h2>βœ… Login Successful</h2>
            <p>Welcome, ${user.profile.displayName}!</p>
            <a href="/profile">View Profile</a>
        `);

    } catch (error) {
        console.error('Login error:', error);
        res.status(500).send('Login failed');
    }
});

// SSO endpoint
app.get('/sso', (req, res) => {
    // Check if user is authenticated
    if (!req.session.user) {
        const { SAMLRequest, RelayState } = req.query;
        return res.redirect(`/login?SAMLRequest=${SAMLRequest}&RelayState=${RelayState}`);
    }

    // Prepare user data for SAML assertion
    const user = req.session.user;
    req.userData = {
        email: user.email,
        displayName: user.profile.displayName,
        firstName: user.profile.firstName,
        lastName: user.profile.lastName,
        department: user.profile.department,
        role: user.profile.role,
        employeeId: user.profile.employeeId
    };

    // Process SAML SSO
    middleware.idp.sso(req, res);
});

// User profile page
app.get('/profile', (req, res) => {
    if (!req.session.user) {
        return res.redirect('/login');
    }

    const user = req.session.user;
    res.send(`
        <h2>πŸ‘€ User Profile</h2>
        <p><strong>Name:</strong> ${user.profile.displayName}</p>
        <p><strong>Email:</strong> ${user.email}</p>
        <p><strong>Department:</strong> ${user.profile.department}</p>
        <p><strong>Role:</strong> ${user.profile.role}</p>
        <p><strong>Employee ID:</strong> ${user.profile.employeeId}</p>
        <a href="/logout">Logout</a>
    `);
});

// Logout
app.get('/logout', (req, res) => {
    req.session.destroy();
    res.send('<h2>Logged out successfully</h2><a href="/login">Login Again</a>');
});

// Health check
app.get('/health', (req, res) => {
    res.json({
        status: 'healthy',
        service: 'SAML Identity Provider',
        encryption: samlHelper.config.encryption,
        timestamp: new Date().toISOString()
    });
});

// Error handling
app.use((error, req, res, next) => {
    console.error('IdP Error:', error);
    res.status(500).json({
        error: 'Identity Provider error',
        message: error.message
    });
});

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`πŸ” SAML Identity Provider running on http://localhost:${PORT}`);
    console.log(`πŸ“‹ Metadata: http://localhost:${PORT}/metadata`);
    console.log(`πŸš€ Login: http://localhost:${PORT}/login`);
    console.log(`πŸ‘€ Profile: http://localhost:${PORT}/profile`);
});

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

Advanced IdP Features

Database Integration

Replace the mock users with real database integration:

// With MongoDB/Mongoose
const User = require('./models/User');

const authenticateUser = async (email, password) => {
    const user = await User.findOne({ email });
    if (!user) return null;

    const isValid = await bcrypt.compare(password, user.password);
    return isValid ? user : null;
};

// With PostgreSQL/Sequelize
const { User } = require('./models');

const authenticateUser = async (email, password) => {
    const user = await User.findOne({ where: { email } });
    if (!user) return null;

    const isValid = await bcrypt.compare(password, user.password);
    return isValid ? user : null;
};
Enter fullscreen mode Exit fullscreen mode

Multi-Factor Authentication

const speakeasy = require('speakeasy');

app.post('/verify-mfa', async (req, res) => {
    const { token } = req.body;
    const user = req.session.user;

    const verified = speakeasy.totp.verify({
        secret: user.mfaSecret,
        encoding: 'base32',
        token: token,
        window: 2
    });

    if (verified) {
        req.session.mfaVerified = true;
        res.redirect('/sso');
    } else {
        res.status(401).send('Invalid MFA token');
    }
});
Enter fullscreen mode Exit fullscreen mode

LDAP/Active Directory Integration

const ldap = require('ldapjs');

const authenticateWithLDAP = (username, password) => {
    return new Promise((resolve, reject) => {
        const client = ldap.createClient({
            url: 'ldap://your-domain-controller.com'
        });

        const dn = `cn=${username},ou=users,dc=company,dc=com`;

        client.bind(dn, password, (err) => {
            if (err) {
                reject(err);
            } else {
                // Fetch user attributes
                client.search(dn, (err, res) => {
                    // Process LDAP response
                    resolve(userInfo);
                });
            }
        });
    });
};
Enter fullscreen mode Exit fullscreen mode

Custom Attribute Mapping

const mapUserAttributes = (user, targetApp) => {
    const attributeMappings = {
        'salesforce': {
            email: user.email,
            'https://schemas.salesforce.com/FirstName': user.firstName,
            'https://schemas.salesforce.com/LastName': user.lastName
        },
        'office365': {
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': user.email,
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname': user.firstName,
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname': user.lastName
        },
        'default': {
            email: user.email,
            displayName: user.displayName,
            firstName: user.firstName,
            lastName: user.lastName
        }
    };

    return attributeMappings[targetApp] || attributeMappings.default;
};
Enter fullscreen mode Exit fullscreen mode

Production Deployment

Environment Configuration

Create .env:

NODE_ENV=production
PORT=3000
SESSION_SECRET=your-super-secret-key-change-this
DATABASE_URL=postgresql://user:pass@host:5432/dbname
LDAP_URL=ldap://domain-controller.company.com
ENTITY_ID=https://idp.company.com/metadata
BASE_URL=https://idp.company.com
SIGNING_CERT_PATH=/app/certs/signing.cert
SIGNING_KEY_PATH=/app/certs/signing.key
ENCRYPT_CERT_PATH=/app/certs/encrypt.cert
ENCRYPT_KEY_PATH=/app/certs/encrypt.key
Enter fullscreen mode Exit fullscreen mode

Docker Deployment

FROM node:18-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .
EXPOSE 3000

CMD ["node", "idp.js"]
Enter fullscreen mode Exit fullscreen mode

Kubernetes Configuration

apiVersion: apps/v1
kind: Deployment
metadata:
  name: saml-idp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: saml-idp
  template:
    metadata:
      labels:
        app: saml-idp
    spec:
      containers:
      - name: saml-idp
        image: your-registry/saml-idp:latest
        ports:
        - containerPort: 3000
        env:
        - name: NODE_ENV
          value: "production"
        volumeMounts:
        - name: certificates
          mountPath: /app/certificates
      volumes:
      - name: certificates
        secret:
          secretName: saml-certificates
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

Certificate Management

// Rotate certificates regularly
const loadCertificates = () => {
    const certPath = process.env.NODE_ENV === 'production' 
        ? '/secure/certificates' 
        : './certificates';

    return {
        signing: {
            key: fs.readFileSync(`${certPath}/signing.key`),
            cert: fs.readFileSync(`${certPath}/signing.cert`)
        },
        encryption: {
            key: fs.readFileSync(`${certPath}/encrypt.key`),
            cert: fs.readFileSync(`${certPath}/encrypt.cert`)
        }
    };
};

// Validate certificate expiration
const validateCertificates = (certificates) => {
    const cert = new crypto.X509Certificate(certificates.signing.cert);
    const expiryDate = new Date(cert.validTo);
    const daysUntilExpiry = (expiryDate - new Date()) / (1000 * 60 * 60 * 24);

    if (daysUntilExpiry < 30) {
        console.warn(`Certificate expires in ${daysUntilExpiry} days!`);
    }
};
Enter fullscreen mode Exit fullscreen mode

Rate Limiting

const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 attempts per window
    message: 'Too many login attempts, please try again later',
    standardHeaders: true,
    legacyHeaders: false
});

app.post('/login', loginLimiter, async (req, res) => {
    // Login logic
});
Enter fullscreen mode Exit fullscreen mode

Audit Logging

const winston = require('winston');

const logger = winston.createLogger({
    level: 'info',
    format: winston.format.json(),
    transports: [
        new winston.transports.File({ filename: 'audit.log' })
    ]
});

const auditLog = (event, user, details) => {
    logger.info({
        timestamp: new Date().toISOString(),
        event,
        userId: user?.id,
        email: user?.email,
        ip: req.ip,
        userAgent: req.get('User-Agent'),
        details
    });
};

// Usage
auditLog('LOGIN_SUCCESS', user, { method: 'password' });
auditLog('SSO_REQUEST', user, { sp: targetApp });
Enter fullscreen mode Exit fullscreen mode

Testing Your IdP

Unit Tests

const request = require('supertest');
const app = require('./idp');

describe('SAML IdP', () => {
    test('should serve metadata', async () => {
        const response = await request(app)
            .get('/metadata')
            .expect(200);

        expect(response.text).toContain('EntityDescriptor');
        expect(response.text).toContain('IDPSSODescriptor');
    });

    test('should authenticate valid user', async () => {
        const response = await request(app)
            .post('/login')
            .send({
                email: 'john.doe@company.com',
                password: 'password123'
            })
            .expect(200);

        expect(response.text).toContain('Login Successful');
    });

    test('should reject invalid credentials', async () => {
        await request(app)
            .post('/login')
            .send({
                email: 'john.doe@company.com',
                password: 'wrongpassword'
            })
            .expect(401);
    });
});
Enter fullscreen mode Exit fullscreen mode

Integration Testing

// Test with a real Service Provider
const testSAMLFlow = async () => {
    // 1. Initiate SSO from SP
    const spResponse = await request(spApp)
        .get('/login')
        .expect(302);

    // 2. Extract SAML request
    const redirectUrl = new URL(spResponse.headers.location);
    const samlRequest = redirectUrl.searchParams.get('SAMLRequest');

    // 3. Submit to IdP
    const idpResponse = await request(idpApp)
        .get('/sso')
        .query({ SAMLRequest: samlRequest })
        .expect(200);

    // 4. Verify SAML response
    expect(idpResponse.text).toContain('SAMLResponse');
};
Enter fullscreen mode Exit fullscreen mode

What's Next?

Congratulations! You now have a fully functional SAML Identity Provider. In Part 2 of this series, we'll cover:

  • 🏒 Service Provider Implementation: How to consume SAML assertions
  • πŸ”„ Complete SSO Flow: End-to-end integration testing
  • πŸš€ Advanced SP Features: Session management, logout, and more
  • πŸ”§ Production Deployment: Scaling and monitoring strategies

Resources


Ready to implement the Service Provider side? Check out Part 2 of this series where we build the receiving end of our SAML SSO flow!

About the Author: Syed Mudaser Ali Kazmi (SMAK) is a seasoned technology leader specializing in solving complex enterprise challenges with elegant, efficient solutions. As Co-founder and Tech Lead at Hucu.ai, he helps organizations achieve maximum operational efficiency using minimal team resources and optimized budgets.

Frustrated by the complexity of enterprise authentication systems, SMAK created the saml-sso-helper package to transform weeks of complex SAML integration work into simple, maintainable systems. His passion lies in democratizing enterprise-grade technology, making sophisticated solutions accessible to teams of all sizes through innovative tooling and clear documentation.

Top comments (0)