Skip to content
Tikab's Toolkit

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 -d

That brings up two stand-ins for whatever a customer runs:

IdPStands in forConfig as code
lldapActive Directory / OpenLDAPexample/idp/lldap/bootstrap/ (users + groups)
DexEntra ID / ADFS / Keycloakexample/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.