hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Production Auth Patterns with @hectoday/http

Before They Start

  • Why Production Auth Is Different
  • Project Setup

Email Verification

  • Why Verify Emails
  • Building Email Verification
  • Restricting Unverified Accounts

Session Management

  • Tracking Sessions Across Devices
  • Listing and Revoking Sessions
  • Session Security

Step-Up Authentication

  • What Is Step-Up Auth
  • Building Step-Up Auth
  • Applying Step-Up to Sensitive Routes

Account Deletion

  • The Right to Be Forgotten
  • Building Account Deletion
  • Data Cleanup

SAML and Enterprise SSO

  • What Is SAML
  • Building a SAML Service Provider
  • Just-in-Time Provisioning

Putting It All Together

  • Production Auth Checklist
  • Capstone: Production-Ready Auth

Building a SAML Service Provider

Setting up the service provider

We use the saml2-js library to handle the XML parsing and signature validation. The library provides a ServiceProvider and IdentityProvider class.

// src/saml.ts
import * as saml2 from "saml2-js";
import db from "./db.js";

const SP_ENTITY_ID = "http://localhost:3000";
const ACS_URL = "http://localhost:3000/auth/saml/callback";

export function createServiceProvider() {
  return new saml2.ServiceProvider({
    entity_id: SP_ENTITY_ID,
    assert_endpoint: ACS_URL,
  });
}

export function createIdentityProvider(config: {
  entityId: string;
  ssoLoginUrl: string;
  certificate: string;
}) {
  return new saml2.IdentityProvider({
    sso_login_url: config.ssoLoginUrl,
    sso_logout_url: config.ssoLoginUrl, // Optional
    certificates: [config.certificate],
  });
}

export function getSamlProviderByDomain(domain: string) {
  return db.prepare("SELECT * FROM saml_providers WHERE domain = ?").get(domain) as
    | {
        id: string;
        domain: string;
        entity_id: string;
        sso_login_url: string;
        certificate: string;
      }
    | undefined;
}

The SAML routes

// src/routes/saml.ts
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { createServiceProvider, createIdentityProvider, getSamlProviderByDomain } from "../saml.js";
import { createSession } from "../sessions.js";
import { sessionCookie } from "../cookies.js";

export const samlRoutes = group([
  // Step 1: Initiate SAML login
  route.post("/auth/saml/login", {
    resolve: async (c) => {
      const body = await c.request.json();
      const email = (body as any).email;

      if (!email) {
        return Response.json({ error: "Email is required" }, { status: 400 });
      }

      // Extract domain from email
      const domain = email.split("@")[1];
      if (!domain) {
        return Response.json({ error: "Invalid email" }, { status: 400 });
      }

      // Look up SAML provider for this domain
      const provider = getSamlProviderByDomain(domain);
      if (!provider) {
        return Response.json({ error: "No SSO configured for this domain" }, { status: 404 });
      }

      const sp = createServiceProvider();
      const idp = createIdentityProvider(provider);

      // Generate the SAML request URL
      return new Promise((resolve) => {
        sp.create_login_request_url(idp, {}, (err: any, loginUrl: string) => {
          if (err) {
            resolve(Response.json({ error: "Failed to create SAML request" }, { status: 500 }));
            return;
          }
          resolve(Response.json({ redirectUrl: loginUrl }));
        });
      });
    },
  }),

  // Step 2: Handle SAML callback (Assertion Consumer Service)
  route.post("/auth/saml/callback", {
    resolve: async (c) => {
      const formData = await c.request.formData();
      const samlResponse = formData.get("SAMLResponse") as string;

      if (!samlResponse) {
        return Response.json({ error: "Missing SAML response" }, { status: 400 });
      }

      // We need to determine which IdP sent this response
      // In practice, the RelayState or response issuer tells us
      // For simplicity, we decode and check the issuer

      const sp = createServiceProvider();

      // Try each configured provider
      const providers = db.prepare("SELECT * FROM saml_providers").all() as any[];

      for (const provider of providers) {
        const idp = createIdentityProvider(provider);

        const result = await new Promise<any>((resolve) => {
          sp.post_assert(
            idp,
            { request_body: { SAMLResponse: samlResponse } },
            (err: any, samlObj: any) => {
              if (err) {
                resolve(null);
                return;
              }
              resolve(samlObj);
            },
          );
        });

        if (result) {
          // Extract user info from the assertion
          const nameId = result.user?.name_id;
          const email = result.user?.attributes?.email?.[0] ?? nameId;
          const name =
            result.user?.attributes?.displayName?.[0] ??
            result.user?.attributes?.name?.[0] ??
            email;

          if (!email) {
            return Response.json({ error: "No email in SAML assertion" }, { status: 400 });
          }

          // Find or create the user (next lesson covers this in detail)
          let user = db
            .prepare("SELECT id, email, name FROM users WHERE email = ?")
            .get(email) as any;

          if (!user) {
            // Just-in-time provisioning
            const id = crypto.randomUUID();
            db.prepare(
              "INSERT INTO users (id, email, name, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)",
            ).run(id, email, name, "saml-no-password", 1);
            user = { id, email, name };

            // Link the SAML identity
            db.prepare(
              "INSERT INTO saml_identities (id, user_id, provider_id, name_id) VALUES (?, ?, ?, ?)",
            ).run(crypto.randomUUID(), id, provider.id, nameId);
          }

          // Create session
          const sessionId = createSession(user.id);

          // Redirect to the app (with session cookie)
          return new Response(null, {
            status: 302,
            headers: {
              location: "/",
              "set-cookie": sessionCookie(sessionId),
            },
          });
        }
      }

      return Response.json({ error: "SAML validation failed" }, { status: 401 });
    },
  }),
]);

How it works

Step 1: Initiate. The user enters their email. The server extracts the domain, looks up the SAML provider, and generates a redirect URL to the IdP’s login page.

Step 2: Callback. The IdP posts a SAML response (signed XML) to your callback URL. The server validates the signature against the provider’s certificate, extracts the user’s email and name, and creates a session.

The callback uses the HTTP POST binding: the IdP submits a form to your callback URL with the SAML response as a form field. This is different from OAuth’s redirect with query parameters.

Configuring a provider

An admin configures SAML for a customer’s domain:

route.post("/admin/saml-providers", {
  resolve: async (c) => {
    // Admin authentication check...

    const body = await c.request.json();
    const { domain, entityId, ssoLoginUrl, certificate } = body;

    const id = crypto.randomUUID();
    db.prepare(
      "INSERT INTO saml_providers (id, domain, entity_id, sso_login_url, certificate) VALUES (?, ?, ?, ?, ?)",
    ).run(id, domain, entityId, ssoLoginUrl, certificate);

    return Response.json({ id, domain }, { status: 201 });
  },
});

The customer provides their IdP’s metadata: entity ID, SSO login URL, and X.509 certificate.

Exercises

Exercise 1: Look at the saml_providers table. What information does each row contain? How does the server use it to validate SAML responses?

Exercise 2: Trace the SAML flow step by step. Where does the user’s browser go at each step? What data is exchanged?

Exercise 3: Compare the SAML callback to the OAuth callback from the OAuth course. Both receive an assertion of identity from an external provider. What are the differences in format and transport?

How does the server validate that a SAML response is legitimate?

← What Is SAML Just-in-Time Provisioning →

© 2026 hectoday. All rights reserved.