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?