Enterprise sign-in
The Django parallel is django-allauth + django-auth-ldap: let staff sign in
with the credentials they already have — a directory account or corporate SSO —
instead of a new password. Built on the auth core, both halves
env-gated with the stack's usual graceful degradation, and both
e2e-tested against local identity providers that run as code.
docker compose --profile idp up -dThat brings up two stand-ins for whatever a customer runs:
| IdP | Stands in for | Config as code |
|---|---|---|
| lldap | Active Directory / OpenLDAP | example/idp/lldap/bootstrap/ (users + groups) |
| Dex | Entra ID / ADFS / Keycloak | example/idp/dex/config.yml (clients + test users) |
LDAP directory sign-in
LDAP_ENABLED + the LDAP_* variables turn on a custom Better Auth endpoint
(/api/auth/sign-in/ldap). It service-binds, looks the user up by username
or email with a directory-portable filter (no AD-only objectCategory
clause, so it works against lldap and AD alike), then binds as that user's DN
with the supplied password — the directory is the verdict, the app never
stores a password. On success the user is provisioned on first sign-in
(emailVerified, since the directory vouched) and a session opens.
/**
* POST /api/auth/sign-in/ldap { login, password }
*
* Service-bind → look the user up by username OR email → bind as their DN
* with the supplied password. On success: find-or-create the app user by
* email (emailVerified — the directory IS the verification) and open a
* session. Deactivated users are refused — same rule as the session choke
* point.
*/
export function ldapSignIn(): BetterAuthPlugin {
return {
id: "ldap",
endpoints: {
signInLdap: createAuthEndpoint(
"/sign-in/ldap",
{
method: "POST",
body: z.object({
login: z.string().min(1).max(200),
password: z.string().min(1).max(200),
}),
},
async (ctx) => {
const config = loadLdapConfigFromEnv();
if (!config.enabled) {
throw new APIError("NOT_FOUND", { message: "LDAP sign-in is not enabled" });
}
const directory = createLdaptsDirectory(config);
const entry = await directory.verifyCredentials(ctx.body.login, ctx.body.password);
if (!entry) {
// One answer for unknown user and wrong password alike.
throw new APIError("UNAUTHORIZED", { message: "Invalid credentials" });
}
const profile = mapEntryToUser(entry, config.attributes);
const email = profile.email?.toLowerCase();
if (!email) {
throw new APIError("UNAUTHORIZED", {
message: "Directory account has no email attribute",
});
}
let user = await ctx.context.internalAdapter
.findUserByEmail(email)
.then((res) => res?.user ?? null);
if (!user) {
user = await ctx.context.internalAdapter.createUser({
email,
emailVerified: true,
name:
[profile.firstName, profile.lastName].filter(Boolean).join(" ") ||
profile.username ||
email,
});
}
if ((user as { deactivatedAt?: Date | null }).deactivatedAt) {
throw new APIError("UNAUTHORIZED", { message: "Account is deactivated" });
}
const session = await ctx.context.internalAdapter.createSession(user.id);
await setSessionCookie(ctx, { session, user });
return ctx.json({ user: { id: user.id, email: user.email } });
},
),
},
};
}An empty password is rejected before any bind — an empty-password LDAP bind is an anonymous bind, which would otherwise "succeed". The directory's password checking (lockout, complexity, expiry) is inherited for free.
OIDC single sign-on
OIDC_DISCOVERY_URL + OIDC_CLIENT_ID/SECRET wire Better Auth's
genericOAuth to any OpenID Connect provider. Locally that is Dex; a
customer's Entra ID, ADFS or Keycloak is the same three variables with
different values — no code change.
/**
* The OIDC half: Better Auth's genericOAuth pointed at whatever the
* environment says. Locally that is Dex; against a customer's Entra ID or
* ADFS it is the same three variables with different values.
*/
export function oidcPlugins() {
if (!isOidcSignInEnabled()) return [];
return [
genericOAuth({
config: [
{
providerId: OIDC_PROVIDER_ID,
discoveryUrl: process.env.OIDC_DISCOVERY_URL!,
clientId: process.env.OIDC_CLIENT_ID!,
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
scopes: ["openid", "email", "profile"],
},
],
}),
];
}The login page reads which methods are enabled and shows the matching buttons — the directory-account button and the SSO button only appear when their environment is configured.
Kerberos / smartcard
@repo/edge-sso (SPNEGO negotiation) stays edge-terminated: the reverse
proxy authenticates the browser against the domain and forwards the identity,
which the app trusts. That needs a real AD + keytab, so it is wired at
deployment, not in the example — but the Caddy front door
is the place it attaches, and the path is the same shape as the OIDC wiring.
How it is tested
The seams have a sandbox page (/sandbox/enterprise-auth) that binds against
the directory and fetches OIDC discovery live. The full journeys run end to
end in e2e/enterprise-auth.spec.ts:
- LDAP — type the directory account into the login form, the password is verified by an actual bind against lldap, the user is provisioned and lands in the app; a wrong password is refused.
- OIDC — click SSO, authenticate on Dex's own screen, land back in the app with a session.
Fixture accounts (in example/idp/): directory dir.user@example.com /
DirUserPass123, SSO sso.user@example.com / password.