Just-in-Time Provisioning
Users who have never visited your app
When a company sets up SAML SSO, their employees do not sign up on your app individually. Instead, the first time an employee logs in via SSO, your app creates their account automatically. This is just-in-time (JIT) provisioning.
The SAML assertion contains the user’s email, name, and often their department, role, or group memberships. Your app uses this information to create the user account and assign appropriate permissions.
The provisioning flow
// src/jit-provisioning.ts
import db from "./db.js";
interface SamlUser {
email: string;
name: string;
nameId: string;
providerId: string;
groups?: string[];
}
export function provisionSamlUser(samlUser: SamlUser): { id: string; email: string; name: string } {
// Check if user already exists
let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(samlUser.email) as
| { id: string; email: string; name: string }
| undefined;
if (user) {
// Update name if changed in the IdP
db.prepare("UPDATE users SET name = ? WHERE id = ?").run(samlUser.name, user.id);
// Ensure SAML identity link exists
const link = db
.prepare("SELECT id FROM saml_identities WHERE user_id = ? AND provider_id = ?")
.get(user.id, samlUser.providerId);
if (!link) {
db.prepare(
"INSERT INTO saml_identities (id, user_id, provider_id, name_id) VALUES (?, ?, ?, ?)",
).run(crypto.randomUUID(), user.id, samlUser.providerId, samlUser.nameId);
}
return user;
}
// Create new user
const id = crypto.randomUUID();
db.prepare(
"INSERT INTO users (id, email, name, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)",
).run(id, samlUser.email, samlUser.name, "saml-no-password", 1);
// Link SAML identity
db.prepare(
"INSERT INTO saml_identities (id, user_id, provider_id, name_id) VALUES (?, ?, ?, ?)",
).run(crypto.randomUUID(), id, samlUser.providerId, samlUser.nameId);
// Map groups to roles (if the IdP provides group info)
if (samlUser.groups) {
mapGroupsToRoles(id, samlUser.providerId, samlUser.groups);
}
return { id, email: samlUser.email, name: samlUser.name };
} Key details:
No password. SSO users authenticate through their IdP, not through your app’s password system. The password_hash is set to a placeholder ("saml-no-password") that can never match a bcrypt comparison. The user cannot log in with a password — only through SSO.
Email verified automatically. The IdP verified the user’s email when the company set up their account. Your app trusts the IdP’s assertion.
Name sync. If the user’s name changes in the IdP (marriage, preferred name update), the next SSO login updates it in your app.
Mapping groups to roles
Enterprise IdPs often provide group memberships in the SAML assertion. Your app can map these to your role system:
function mapGroupsToRoles(userId: string, providerId: string, groups: string[]): void {
// Get the group-to-role mapping for this provider
const provider = db.prepare("SELECT domain FROM saml_providers WHERE id = ?").get(providerId) as {
domain: string;
};
// Find the organization for this domain
// (In a real app, the admin configures which org maps to which SSO provider)
const org = db
.prepare("SELECT id FROM organizations WHERE id = ?")
.get(`org-${provider.domain.split(".")[0]}`) as { id: string } | undefined;
if (!org) return;
// Map groups to roles
let role = "viewer"; // default
if (groups.includes("admins") || groups.includes("owners")) {
role = "owner";
} else if (groups.includes("editors") || groups.includes("developers")) {
role = "editor";
}
// Create or update membership
const existing = db
.prepare("SELECT id FROM memberships WHERE user_id = ? AND org_id = ?")
.get(userId, org.id);
if (existing) {
db.prepare("UPDATE memberships SET role = ? WHERE user_id = ? AND org_id = ?").run(
role,
userId,
org.id,
);
} else {
db.prepare("INSERT INTO memberships (id, user_id, org_id, role) VALUES (?, ?, ?, ?)").run(
crypto.randomUUID(),
userId,
org.id,
role,
);
}
} The group-to-role mapping is configurable. A simple approach: “admins” group → owner role, “developers” group → editor role, everyone else → viewer. The specific mapping depends on the customer’s organization.
Domain-based SSO enforcement
Once a company configures SSO, you can enforce it: all users with that company’s email domain must log in via SSO. They cannot use password login.
// In the login route, before checking the password:
const domain = email.split("@")[1];
const samlProvider = getSamlProviderByDomain(domain);
if (samlProvider) {
return Response.json(
{
error: "This domain uses SSO. Please log in via your company's identity provider.",
ssoRequired: true,
redirectUrl: "/auth/saml/login",
},
{ status: 403 },
);
} This prevents employees from bypassing SSO by creating a password-based account. All authentication for that domain goes through the company’s IdP, which enforces the company’s security policies (MFA, password rules, session length).
Exercises
Exercise 1: Configure a test SAML provider for acmecorp.com. Simulate a SAML login. Verify the user is created automatically with email_verified = 1.
Exercise 2: Add group-to-role mapping. Simulate a login with groups: ["developers"]. Verify the user gets the “editor” role.
Exercise 3: Enable domain enforcement. Try logging in with a password for an acmecorp.com email. It should be blocked with an SSO redirect.
Why do SSO users have 'saml-no-password' instead of a real password hash?