Files
cms.fellies.org/apps/admin/src/lib/auth/server.ts

523 lines
12 KiB
TypeScript

import { normalizeRole, type Role } from "@cms/content/rbac"
import { db, isAdminSelfRegistrationEnabled } from "@cms/db"
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { toNextJsHandler } from "better-auth/next-js"
const FALLBACK_DEV_SECRET = "dev-only-change-me-for-production"
const isProduction = process.env.NODE_ENV === "production"
const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001"
const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
const DEFAULT_SUPPORT_USERNAME = "support"
const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password"
const DEFAULT_SUPPORT_NAME = "Technical Support"
const DEFAULT_SUPPORT_LOGIN_KEY = "support-access"
const USERNAME_MAX_LENGTH = 32
function resolveAuthSecret(): string {
const value = process.env.BETTER_AUTH_SECRET
if (value) {
return value
}
if (isProduction) {
throw new Error("BETTER_AUTH_SECRET is required in production")
}
return FALLBACK_DEV_SECRET
}
export async function hasOwnerUser(): Promise<boolean> {
const ownerCount = await db.user.count({
where: { role: "owner" },
})
return ownerCount > 0
}
export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
return !(await hasOwnerUser())
}
export async function isSelfRegistrationEnabled(): Promise<boolean> {
return isAdminSelfRegistrationEnabled()
}
export async function canUserSelfRegister(): Promise<boolean> {
if (!(await hasOwnerUser())) {
return true
}
return isSelfRegistrationEnabled()
}
export function resolveSupportLoginKey(): string {
const value = process.env.CMS_SUPPORT_LOGIN_KEY
if (value) {
return value
}
if (isProduction) {
throw new Error("CMS_SUPPORT_LOGIN_KEY is required in production")
}
return DEFAULT_SUPPORT_LOGIN_KEY
}
function resolveBootstrapValue(
envKey: string,
fallback: string,
options: {
requiredInProduction?: boolean
} = {},
): string {
const value = process.env[envKey]
if (value) {
return value
}
if (isProduction && options.requiredInProduction) {
throw new Error(`${envKey} is required in production`)
}
return fallback
}
function normalizeUsernameCandidate(input: string | null | undefined): string | null {
if (!input) {
return null
}
const normalized = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, USERNAME_MAX_LENGTH)
if (!normalized) {
return null
}
return normalized
}
function extractEmailLocalPart(email: string): string {
return email.split("@")[0] ?? email
}
async function getAvailableUsername(base: string): Promise<string> {
const normalizedBase = normalizeUsernameCandidate(base) ?? "user"
for (let suffix = 0; suffix < 1000; suffix += 1) {
const candidate =
suffix === 0 ? normalizedBase : `${normalizedBase}-${suffix}`.slice(0, USERNAME_MAX_LENGTH)
const existing = await db.user.findUnique({
where: { username: candidate },
select: { id: true },
})
if (!existing) {
return candidate
}
}
throw new Error("Unable to allocate unique username")
}
export async function ensureUserUsername(
userId: string,
options: {
preferred?: string | null | undefined
fallbackEmail?: string | null | undefined
fallbackName?: string | null | undefined
} = {},
): Promise<string | null> {
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, username: true, email: true, name: true },
})
if (!user) {
return null
}
if (user.username) {
return user.username
}
const baseCandidate =
normalizeUsernameCandidate(options.preferred) ??
normalizeUsernameCandidate(
options.fallbackEmail ? extractEmailLocalPart(options.fallbackEmail) : null,
) ??
normalizeUsernameCandidate(options.fallbackName) ??
normalizeUsernameCandidate(extractEmailLocalPart(user.email)) ??
normalizeUsernameCandidate(user.name) ??
"user"
const username = await getAvailableUsername(baseCandidate)
await db.user.update({
where: { id: user.id },
data: { username },
})
return username
}
export async function resolveEmailFromLoginIdentifier(
identifier: string | null | undefined,
): Promise<string | null> {
const value = identifier?.trim()
if (!value) {
return null
}
if (value.includes("@")) {
return value.toLowerCase()
}
const username = normalizeUsernameCandidate(value)
if (!username) {
return null
}
const user = await db.user.findUnique({
where: { username },
select: { email: true },
})
return user?.email ?? null
}
export const auth = betterAuth({
appName: "CMS Admin",
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
secret: resolveAuthSecret(),
trustedOrigins: [adminOrigin, webOrigin],
database: prismaAdapter(db, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
// Sign-up gating is handled in route layer so we can close registration
// automatically after the first owner account is created.
disableSignUp: false,
},
user: {
additionalFields: {
role: {
type: "string",
required: true,
defaultValue: "editor",
input: false,
},
username: {
type: "string",
required: false,
input: false,
},
isBanned: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
isSystem: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
isHidden: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
isProtected: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
},
},
})
export const authRouteHandlers = toNextJsHandler(auth)
export type AuthSession = typeof auth.$Infer.Session
let supportBootstrapPromise: Promise<void> | null = null
type BootstrapUserConfig = {
email: string
username: string
name: string
password: string
role: Role
isHidden: boolean
}
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
const ctx = await auth.$context
const normalizedEmail = config.email.toLowerCase()
const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, {
includeAccounts: true,
})
if (existing?.user) {
await db.user.update({
where: { id: existing.user.id },
data: {
name: config.name,
role: config.role,
isBanned: false,
isSystem: true,
isHidden: config.isHidden,
isProtected: true,
},
})
const hasCredentialAccount = existing.accounts.some(
(account) => account.providerId === "credential",
)
if (!hasCredentialAccount) {
const passwordHash = await ctx.password.hash(config.password)
await ctx.internalAdapter.linkAccount({
userId: existing.user.id,
providerId: "credential",
accountId: existing.user.id,
password: passwordHash,
})
}
await ensureUserUsername(existing.user.id, {
preferred: config.username,
fallbackEmail: existing.user.email,
fallbackName: config.name,
})
return
}
const availableUsername = await getAvailableUsername(config.username)
const passwordHash = await ctx.password.hash(config.password)
const createdUser = await ctx.internalAdapter.createUser({
name: config.name,
email: normalizedEmail,
username: availableUsername,
emailVerified: true,
role: config.role,
isBanned: false,
isSystem: true,
isHidden: config.isHidden,
isProtected: true,
})
await ctx.internalAdapter.linkAccount({
userId: createdUser.id,
providerId: "credential",
accountId: createdUser.id,
password: passwordHash,
})
}
async function bootstrapSystemUsers(): Promise<void> {
const supportUsername = resolveBootstrapValue("CMS_SUPPORT_USERNAME", DEFAULT_SUPPORT_USERNAME)
const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", `${supportUsername}@cms.local`)
const supportPassword = resolveBootstrapValue("CMS_SUPPORT_PASSWORD", DEFAULT_SUPPORT_PASSWORD, {
requiredInProduction: true,
})
const supportName = resolveBootstrapValue("CMS_SUPPORT_NAME", DEFAULT_SUPPORT_NAME)
await ensureCredentialUser({
email: supportEmail,
username: supportUsername,
name: supportName,
password: supportPassword,
role: "support",
isHidden: true,
})
}
export async function ensureSupportUserBootstrap(): Promise<void> {
if (supportBootstrapPromise) {
await supportBootstrapPromise
return
}
supportBootstrapPromise = (async () => {
await bootstrapSystemUsers()
await enforceOwnerInvariant()
})()
try {
await supportBootstrapPromise
} catch (error) {
supportBootstrapPromise = null
throw error
}
}
type OwnerInvariantState = {
ownerId: string | null
ownerCount: number
repaired: boolean
}
export async function enforceOwnerInvariant(): Promise<OwnerInvariantState> {
return db.$transaction(async (tx) => {
const owners = await tx.user.findMany({
where: { role: "owner" },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { id: true, isProtected: true, isBanned: true },
})
if (owners.length === 0) {
const candidate = await tx.user.findFirst({
where: {
role: {
not: "support",
},
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { id: true },
})
if (!candidate) {
return {
ownerId: null,
ownerCount: 0,
repaired: false,
}
}
await tx.user.update({
where: { id: candidate.id },
data: {
role: "owner",
isProtected: true,
isBanned: false,
},
})
return {
ownerId: candidate.id,
ownerCount: 1,
repaired: true,
}
}
const canonicalOwner = owners[0]
const extraOwnerIds = owners.slice(1).map((owner) => owner.id)
if (extraOwnerIds.length > 0) {
await tx.user.updateMany({
where: { id: { in: extraOwnerIds } },
data: {
role: "admin",
isProtected: false,
},
})
}
if (!canonicalOwner.isProtected || canonicalOwner.isBanned) {
await tx.user.update({
where: { id: canonicalOwner.id },
data: {
isProtected: true,
isBanned: false,
},
})
}
return {
ownerId: canonicalOwner.id,
ownerCount: 1,
repaired: extraOwnerIds.length > 0 || !canonicalOwner.isProtected || canonicalOwner.isBanned,
}
})
}
export async function canDeleteUserAccount(userId: string): Promise<boolean> {
const user = await db.user.findUnique({
where: { id: userId },
select: { role: true, isProtected: true },
})
if (!user) {
return false
}
// Protected/system users (support + canonical owner) are never deletable
// through self-service endpoints.
if (user.isProtected) {
return false
}
if (user.role !== "owner") {
return true
}
// Defensive fallback for drifted data; normal flow should already keep one owner.
const ownerCount = await db.user.count({
where: { role: "owner" },
})
return ownerCount > 1
}
export async function promoteFirstRegisteredUserToOwner(userId: string): Promise<boolean> {
const promoted = await db.$transaction(async (tx) => {
const existingOwner = await tx.user.findFirst({
where: { role: "owner" },
select: { id: true },
})
if (existingOwner) {
return false
}
await tx.user.update({
where: { id: userId },
data: {
role: "owner",
isSystem: false,
isHidden: false,
isProtected: true,
isBanned: false,
},
})
return true
})
if (promoted) {
await enforceOwnerInvariant()
}
return promoted
}
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
const sessionUserRole = session?.user?.role
if (typeof sessionUserRole !== "string") {
return null
}
return normalizeRole(sessionUserRole)
}