Ctrl K
ComunidadGuías…

lib/auth/better-auth/config.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { APIError } from 'better-auth/api'
import { nextCookies } from 'better-auth/next-js'
import { admin, genericOAuth } from 'better-auth/plugins'
import { asc, count, eq, sql } from 'drizzle-orm'

import { db } from '@/lib/db'

import {
  getOidcProviderId,
  getOidcProviderName as getOidcProviderNameUtil,
  isOidcConfigured as isOidcConfiguredUtil
} from '../oauth-config'

import { isSmtpConfigured, sendSmtpMail } from './mailer'
import * as authSchema from './schema'

export type SignUpMode = 'open' | 'invite'

/**
 * Password reset and invitation emails require SMTP. The `passwordReset`
 * capability of the better-auth provider is gated on this at runtime.
 */
export function isBetterAuthSmtpConfigured(): boolean {
  return isSmtpConfigured()
}

/**
 * True when OIDC is configured: all required environment variables are set.
 * OIDC_SCOPE is optional and defaults to 'openid profile email'.
 */
export function isOidcConfigured(): boolean {
  return isOidcConfiguredUtil()
}

export function getOidcProviderName(): string {
  return getOidcProviderNameUtil()
}

/**
 * Canonical origin for absolute links delivered by email. Never derived
 * from request headers: `Origin` and `x-forwarded-host` are caller-
 * controlled, and a spoofed value would deliver a valid one-time
 * credential to an attacker domain (host-header injection). Deployments
 * that deliver email must set `BETTER_AUTH_URL`.
 */
export function getEmailLinkOrigin(): string {
  return (process.env.BETTER_AUTH_URL ?? '').trim().replace(/\/+$/, '')
}

export function getSignUpMode(): SignUpMode {
  const raw = process.env.AUTH_SIGNUP_MODE?.trim().toLowerCase()
  if (!raw) {
    return 'open' // documented default
  }
  if (raw === 'open' || raw === 'invite') {
    return raw
  }
  // A typo must not silently open an invite-only instance to the internet:
  // fail loudly instead of guessing (same policy as BETTER_AUTH_SECRET).
  throw new Error(
    `Invalid AUTH_SIGNUP_MODE "${process.env.AUTH_SIGNUP_MODE}". ` +
      'Expected "open" or "invite".'
  )
}

function getSecret(): string {
  const secret = process.env.BETTER_AUTH_SECRET?.trim()
  if (secret) {
    return secret
  }
  if (process.env.NODE_ENV === 'production') {
    // Never fall back to a publicly known secret in production: better-auth
    // must fail fast instead of silently signing with a shared default.
    throw new Error(
      'BETTER_AUTH_SECRET must be set when running AUTH_PROVIDER=better-auth.'
    )
  }
  if (process.env.NODE_ENV !== 'test') {
    console.warn(
      '⚠️  BETTER_AUTH_SECRET is not set. Using an insecure development ' +
        'secret. Set BETTER_AUTH_SECRET to a stable random value.'
    )
  }
  return 'morphic-better-auth-development-secret'
}

/**
 * Sign-up gate for the bootstrap window: while the user table is empty and
 * `BOOTSTRAP_ADMIN_EMAIL` is set, only that address may sign up. The admin
 * role itself is granted by `claimBootstrapAdmin` once the row exists.
 */
export async function applyBootstrapAdminGate(data: {
  email?: string
}): Promise<void> {
  const [existing] = await db.select({ total: count() }).from(authSchema.user)

  if ((existing?.total ?? 0) > 0) {
    return // Bootstrap window is closed
  }

  const gate = process.env.BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase()
  if (gate && data.email?.trim().toLowerCase() !== gate) {
    throw new APIError('FORBIDDEN', {
      message:
        'Sign-up is restricted to the configured BOOTSTRAP_ADMIN_EMAIL address.'
    })
  }
}

/**
 * True while the bootstrap window is open: `BOOTSTRAP_ADMIN_EMAIL` is set
 * and the user table is still empty. The sign-up page uses this to render
 * the form (without a token) on a fresh invite-only instance; the address
 * itself is enforced by `applyBootstrapAdminGate` at creation time.
 */
export async function isBootstrapWindowOpen(): Promise<boolean> {
  if (!process.env.BOOTSTRAP_ADMIN_EMAIL?.trim()) {
    return false
  }
  const [existing] = await db.select({ total: count() }).from(authSchema.user)
  return (existing?.total ?? 0) === 0
}

/**
 * True when the email is the seed administrator of a fresh instance:
 * `BOOTSTRAP_ADMIN_EMAIL` is set, the address matches, and the user table
 * is still empty. Invite-only instances use this to let the first admin
 * sign up without an invitation — creating invitations requires an admin,
 * so the instance would otherwise have no way to bootstrap.
 */
export async function isBootstrapAccount(email: string): Promise<boolean> {
  const gate = process.env.BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase()
  if (!gate || email.trim().toLowerCase() !== gate) {
    return false
  }
  return isBootstrapWindowOpen()
}

/**
 * Runs an admin-election transaction with retries. A serialization
 * failure (SQLSTATE 40001) does not by itself prove a concurrent
 * claimant committed: both transactions can abort. Retry it like any
 * other transient failure — a real winner shows up as an existing admin
 * on the next attempt, and if nobody won the retry completes the
 * election instead of stranding the instance without an admin.
 */
async function withElectionRetry(
  label: string,
  run: () => Promise<void>
): Promise<void> {
  // Bounded on purpose: the hook runs inside the sign-up (or deletion)
  // request after the account work already committed, so retrying
  // forever would hang the response indefinitely. The window is short
  // but covers transient blips; exhaustion is a repair-needed condition
  // logged loudly — the election is deterministic, so a single manual
  // promotion restores the invariant.
  const maxAttempts = 6
  for (let attempt = 1; ; attempt++) {
    try {
      await run()
      return
    } catch (error) {
      if (attempt >= maxAttempts) {
        console.error(
          `${label} failed after ${maxAttempts} attempts — repair needed. ` +
            'If the instance is now without an admin, promote the intended ' +
            `account directly: UPDATE "user" SET role = 'admin' ` +
            "WHERE id = '<account id>'. Later sign-ups cannot take the " +
            'claim because the election requires the earliest account.',
          error
        )
        return
      }
      await new Promise(resolve =>
        setTimeout(resolve, Math.min(100 * 2 ** (attempt - 1), 500))
      )
    }
  }
}

/**
 * Grant the admin role to the very first account.
 *
 * The after-create hook runs in its own transaction once the sign-up has
 * committed, so two concurrent first sign-ups can each observe the other's
 * committed row: a "no other user exists" condition would make both decline
 * and leave the instance without any admin. The claim is therefore a
 * deterministic election — the role goes to the earliest account (createdAt,
 * tie-broken by id) whenever no admin exists. Exactly one of any set of
 * concurrent sign-ups can be the earliest, so exactly one account claims the
 * role regardless of interleaving.
 *
 * "No admin exists" alone would allow escalation: if the sole admin later
 * deletes their account while regular members remain, the next registrant
 * must not inherit the role — and it cannot be the earliest account, so the
 * election declines it. Re-bootstrapping then requires removing every
 * account (the bootstrap window reopens with an empty table) or promoting a
 * successor directly in the database.
 */
export async function claimBootstrapAdmin(userId: string): Promise<void> {
  // A transient failure here (e.g. the connection drops after the user
  // insert already committed) must not strand the instance without an
  // admin: the earliest-account guard means no later sign-up can take the
  // claim over, so retry before giving up.
  await withElectionRetry('Bootstrap admin claim', () =>
    db.transaction(async tx => {
      await tx.execute(sql`set transaction isolation level serializable`)
      const [admin] = await tx
        .select({ id: authSchema.user.id })
        .from(authSchema.user)
        .where(eq(authSchema.user.role, 'admin'))
        .limit(1)
      if (admin) {
        return // An admin exists: the bootstrap window is closed
      }
      const [earliest] = await tx
        .select({ id: authSchema.user.id })
        .from(authSchema.user)
        .orderBy(asc(authSchema.user.createdAt), asc(authSchema.user.id))
        .limit(1)
      if (earliest?.id !== userId) {
        return // Not the earliest account: another sign-up owns the claim
      }
      await tx
        .update(authSchema.user)
        .set({ role: 'admin' })
        .where(eq(authSchema.user.id, userId))
    })
  )
}

/**
 * Completes the bootstrap election after the last admin account is
 * deleted. A sign-up that commits while the deletion is in flight sees
 * the admin row in its own claim and declines; the deletion's locked
 * count in turn cannot see the uncommitted insert. Running the election
 * again after the deletion commits closes the window: the sign-up's
 * claim runs after its insert commits and the re-election runs after
 * the deletion commits, so neither can miss the other without a
 * timestamp cycle — at least one of them observes both states and the
 * earliest account takes the role.
 */
export async function reElectBootstrapAdmin(): Promise<void> {
  await withElectionRetry('Bootstrap admin re-election', () =>
    db.transaction(async tx => {
      await tx.execute(sql`set transaction isolation level serializable`)
      const [admin] = await tx
        .select({ id: authSchema.user.id })
        .from(authSchema.user)
        .where(eq(authSchema.user.role, 'admin'))
        .limit(1)
      if (admin) {
        return // An admin exists: nothing to re-elect
      }
      const [earliest] = await tx
        .select({ id: authSchema.user.id })
        .from(authSchema.user)
        .orderBy(asc(authSchema.user.createdAt), asc(authSchema.user.id))
        .limit(1)
      if (!earliest) {
        return // Empty table: the bootstrap window is the recovery path
      }
      await tx
        .update(authSchema.user)
        .set({ role: 'admin' })
        .where(eq(authSchema.user.id, earliest.id))
    })
  )
}

/**
 * Count the rows of the auth user table (live read, no caching).
 */
export async function countAuthUsers(): Promise<number> {
  const [row] = await db.select({ total: count() }).from(authSchema.user)
  return row?.total ?? 0
}

/**
 * Policy gate for implicit account creation through the generic OAuth/OIDC
 * provider, run by Better Auth's `user.validateUserInfo` seam whenever a
 * brand-new user record is about to be created. It resolves the live
 * user-table state on every call, so no gate state needs to be cached,
 * calibrated or reset across requests: the first account closing the
 * bootstrap window ends the restriction on the spot, in every process.
 *
 * Rules for `oauth`-source `create-user` actions:
 *
 * - `AUTH_SIGNUP_MODE=invite` (or an invalid mode): reject — members of
 *   your IdP tenant are never handed an account implicitly. OIDC sign-in
 *   works only for an identity already linked to a local account; an
 *   account created through the credential flow (invitation token) is not
 *   linked automatically, so invited members sign in with email and
 *   password.
 * - Open mode with an empty user table: reject — the first account can
 *   only come through the credential/bootstrap path, where the emailed
 *   bootstrap link proves ownership of `BOOTSTRAP_ADMIN_EMAIL`; an OAuth
 *   callback cannot prove mailbox ownership.
 * - Open mode otherwise: allow.
 *
 * Unaffected: credential sign-ups (invitation and bootstrap-address
 * policy live in their own flow) and an existing identity signing in
 * again or linking its account — those passed this gate when their
 * account was created.
 *
 * Fails closed: if the user count cannot be read, the sign-up is
 * rejected rather than silently allowed (Better Auth surfaces the
 * returned `error` as a 403 and redirects the callback to its error
 * endpoint).
 */
export async function validateOidcUserCreation(
  data: {
    source?: { action?: string; method?: string } | null | undefined
  },
  countUsers: () => Promise<number>
): Promise<{ error: string; errorDescription?: string } | undefined> {
  const source = data?.source
  if (!source || source.action !== 'create-user' || source.method !== 'oauth') {
    return // The gate applies only to new accounts arriving through OAuth.
  }

  try {
    if (getSignUpMode() !== 'open') {
      return {
        error: 'sign_up_not_allowed',
        errorDescription:
          'Registration is by invitation. Sign in if you already have an account.'
      }
    }
  } catch (error) {
    console.warn('AUTH_SIGNUP_MODE is invalid — OIDC sign-up rejected.', error)
    return {
      error: 'sign_up_unavailable',
      errorDescription: 'User registration is not available.'
    }
  }

  try {
    if ((await countUsers()) === 0) {
      return {
        error: 'bootstrap_window_open',
        errorDescription:
          'The first account of this deployment must be created through its emailed bootstrap link.'
      }
    }
  } catch (error) {
    console.warn(
      'Could not read the user table to validate OIDC sign-up ' +
        '(bootstrap window unknown) — rejecting the registration.',
      error
    )
    return {
      error: 'sign_up_unavailable',
      errorDescription:
        'Registration cannot be validated right now, please try again.'
    }
  }

  return undefined
}

function createAuth() {
  const smtpConfigured = isBetterAuthSmtpConfigured()
  const oidcConfigured = isOidcConfigured()

  // Conditionally spread the OIDC plugin inline so the concrete plugin
  // types reach betterAuth(): a `plugins: any[]` assertion would strip
  // every declared endpoint (e.g. admin's setRole/setUserPassword) from
  // the inferred auth.api type.
  const oidcPlugins = oidcConfigured
    ? [
        genericOAuth({
          config: [
            {
              // The provider id the plugin registers and the IdP callback
              // URL is derived from (OIDC_PROVIDER_ID, default 'oidc').
              providerId: getOidcProviderId(),
              // Trailing slash is a common typo: without stripping, the
              // discovery URL becomes "...//.well-known/..." and most IdPs
              // answer 404, which better-auth reports as "discovery left no
              // usable authorization endpoint or token exchange".
              discoveryUrl: `${process.env.OIDC_ISSUER?.trim().replace(/\/+$/, '')}/.well-known/openid-configuration`,
              clientId: process.env.OIDC_CLIENT_ID?.trim() ?? '',
              clientSecret: process.env.OIDC_CLIENT_SECRET?.trim() ?? '',
              scopes: (
                process.env.OIDC_SCOPE?.trim() || 'openid profile email'
              ).split(' '),
              pkce: true,
              // Map OIDC claims to Better Auth user fields
              // Note: Do not map 'id' here - Better Auth gets user identity from
              // the accountSubject (OIDC 'sub' claim) automatically
              mapProfileToUser: profile => ({
                email: profile.email as string,
                emailVerified: (profile.email_verified ?? false) as boolean,
                name: (profile.name ||
                  profile.preferred_username ||
                  profile.email) as string,
                image: profile.picture as string | undefined
              })
            }
          ]
        })
      ]
    : []

  // nextCookies must stay last: it re-applies the session cookie
  // through next/headers so server actions (signIn/signUp/signOut)
  // deliver it to the browser without a hand-written header copy.

  return betterAuth({
    appName: 'Morphic',
    secret: getSecret(),
    database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }),
    emailAndPassword: {
      enabled: true,
      // better-auth 1.7 removed the built-in nodemailer transport: without
      // this callback, requestPasswordReset fails with RESET_PASSWORD_DISABLED
      // even when the SMTP variables are present. Morphic does not use email
      // verification, so this is the only email better-auth delivers;
      // invitation mail goes through lib/actions/admin.ts.
      sendResetPassword: smtpConfigured
        ? async ({ user, token }) => {
            // better-auth's generated url targets its own
            // /api/auth/reset-password/:token endpoint, which this app does
            // not expose (the mounted HTTP surface is restricted to provider
            // callbacks, see app/api/auth/[...all]/route.ts), so it would 404.
            // Link the in-app page directly with the raw token instead; it
            // resets the password through the provider's server action.
            const origin = getEmailLinkOrigin()
            if (!origin) {
              // Without a canonical origin the link could only be built
              // from caller-controlled headers: fail loudly rather than
              // deliver a live credential to the wrong host.
              throw new Error(
                'BETTER_AUTH_URL must be set to deliver password reset emails'
              )
            }
            const link = `${origin}/auth/update-password?token=${token}`
            await sendSmtpMail({
              to: user.email,
              subject: 'Reset your Morphic password',
              text: `Use this link to reset your password (valid for one hour): ${link}`,
              html: `<p>Use this link to reset your password (valid for one hour):</p><p><a href="${link}">Reset password</a></p>`
            })
          }
        : undefined
    },
    plugins: [admin({ defaultRole: 'user' }), ...oidcPlugins, nextCookies()],
    user: {
      // Policy gate for implicit account creation through the generic
      // OAuth/OIDC provider (sign-up mode + bootstrap window). Runs at
      // user-creation time against the live user table (see
      // validateOidcUserCreation), so no cached gate state is needed.
      validateUserInfo: (data, context) =>
        validateOidcUserCreation(data, countAuthUsers)
    },
    databaseHooks: {
      user: {
        create: {
          before: async user => applyBootstrapAdminGate(user),
          after: async user => {
            await claimBootstrapAdmin(user.id)
          }
        }
      }
    }
  })
}

export type BetterAuth = ReturnType<typeof createAuth>

let authInstance: BetterAuth | null = null

/** Lazily create the shared Better Auth instance. */
/** Lazily create the shared Better Auth instance. */
export function getAuth(): BetterAuth {
  if (!authInstance) {
    authInstance = createAuth()
  }
  return authInstance
}

/** Test seam: reset the memoized instance after changing env vars. */
export function resetAuthInstance(): void {
  authInstance = null
}