Add optional OIDC authentication via Better Auth genericOAuth plugin

Support self-hosted OIDC providers (Authentik, Authelia, Keycloak, etc.)
configured entirely via environment variables. Uses Better Auth's
hooks.before to gate email/password sign-up at the endpoint level, and
disables emailAndPassword entirely when DISABLE_PASSWORD_LOGIN is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 13:39:15 -05:00
co-authored by Claude Opus 4.6
parent e19ac78b39
commit fb785645f8
9 changed files with 320 additions and 135 deletions
+53 -10
View File
@@ -1,8 +1,13 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { admin } from "better-auth/plugins";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { admin, genericOAuth } from "better-auth/plugins";
import { v4 as uuid } from "uuid";
import {
isOidcAutoRegisterEnabled,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
import { db } from "@/lib/db/client";
import {
getUserCount,
@@ -10,23 +15,66 @@ import {
setSetting,
} from "@/lib/services/settings";
const oidcPlugin = isOidcConfigured()
? [
genericOAuth({
config: [
{
providerId: "oidc",
clientId: process.env.OIDC_CLIENT_ID ?? "",
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
discoveryUrl: `${process.env.OIDC_ISSUER_URL}/.well-known/openid-configuration`,
scopes: ["openid", "email", "profile"],
pkce: true,
disableImplicitSignUp: !isOidcAutoRegisterEnabled(),
mapProfileToUser: (profile) => ({
name: profile.name || profile.preferred_username || profile.email,
}),
},
],
}),
]
: [];
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "sqlite",
}),
emailAndPassword: {
enabled: true,
enabled: !isPasswordLoginDisabled(),
},
plugins: [admin()],
account: {
accountLinking: {
enabled: true,
trustedProviders: ["oidc"],
},
},
plugins: [admin(), ...oidcPlugin],
advanced: {
database: {
generateId: () => uuid(),
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Block email/password sign-up when registration is closed.
// This is endpoint-level so it doesn't affect OIDC user creation
// (which is gated by the genericOAuth plugin's disableImplicitSignUp).
if (ctx.path === "/sign-up/email") {
const open = await isRegistrationOpen();
if (!open) {
throw new APIError("FORBIDDEN", {
message: "Registration is currently closed",
});
}
}
}),
},
databaseHooks: {
user: {
create: {
before: async (userData) => {
// First user becomes admin regardless of auth method
const userCount = await getUserCount();
if (userCount === 0) {
return {
@@ -36,15 +84,10 @@ export const auth = betterAuth({
},
};
}
const open = await isRegistrationOpen();
if (!open) {
throw new APIError("FORBIDDEN", {
message: "Registration is currently closed",
});
}
return { data: userData };
},
after: async () => {
// Auto-close registration after first user
const userCount = await getUserCount();
if (userCount === 1) {
await setSetting("registrationOpen", "false");