import { cache } from 'react' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, captcha, customSession, emailOTP, genericOAuth, oneTimeToken, organization, } from 'better-auth/plugins' import { and, count, eq, inArray, sql } from 'drizzle-orm' import { headers } from 'next/headers' import Stripe from 'stripe' import { getEmailSubject, renderExistingAccountEmail, renderOTPEmail, renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy' import { clampExpiryForSession } from '@/lib/auth/session-policy' import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' import { sendPlanWelcomeEmail } from '@/lib/billing' import { assertPersonalCheckoutAllowed, authorizeSubscriptionReference, isPersonalCheckoutRequest, } from '@/lib/billing/authorization' import { getOrganizationIdForSubscriptionReference, syncSubscriptionPlan, writeBillingInterval, } from '@/lib/billing/core/subscription' import { handleNewUser } from '@/lib/billing/core/usage' import { ensureOrganizationForTeamSubscription, syncSubscriptionUsageLimits, } from '@/lib/billing/organization' import { pauseProSubscriptionForOrgCoverage } from '@/lib/billing/organizations/membership' import { isPro, isTeam } from '@/lib/billing/plan-helpers' import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans' import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management' import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout' import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes' import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise' import { handleInvoiceFinalized, handleInvoicePaymentFailed, handleInvoicePaymentSucceeded, } from '@/lib/billing/webhooks/invoices' import { handleSubscriptionCreated, handleSubscriptionDeleted, } from '@/lib/billing/webhooks/subscription' import { env } from '@/lib/core/config/env' import { isAuthDisabled, isBillingEnabled, isEmailPasswordEnabled, isEmailSignupDisabled, isEmailVerificationEnabled, isGithubAuthDisabled, isGoogleAuthDisabled, isHosted, isMicrosoftAuthDisabled, isOrganizationsEnabled, isRegistrationDisabled, isSignupMxValidationEnabled, isSsoEnabled, } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { joinInstanceOrganization } from '@/lib/organizations/instance-org' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' import { disableUserResources } from '@/lib/workflows/lifecycle' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' const logger = createLogger('Auth') const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) => logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value }) ) /** * Extra provider IDs appended to `trustedProviders`, from `SSO_PROVIDER_ID` and * `SSO_TRUSTED_PROVIDER_IDS`. Empty when SSO is disabled. * * These no longer affect SSO sign-in: the plugin passes `trustProviderByName: * false`, disabling the name-based branch, so SSO trust comes only from * `domainVerified`. Kept because non-SSO providers still link by name. */ const additionalTrustedSsoProviders = isSsoEnabled ? [env.SSO_PROVIDER_ID, ...(env.SSO_TRUSTED_PROVIDER_IDS?.split(',') ?? [])] .map((id) => id?.trim()) .filter((id): id is string => Boolean(id)) : [] if (env.NODE_ENV === 'production') { const baseUrl = getBaseUrl() if (isLocalhostUrl(baseUrl)) { logger.warn( 'NEXT_PUBLIC_APP_URL points to localhost in production. Self-hosted deployments must set NEXT_PUBLIC_APP_URL to the public URL users access (e.g. https://sim.example.com), otherwise auth POST requests from any non-localhost origin will be rejected by trustedOrigins. Set TRUSTED_ORIGINS to allow additional public origins.', { baseUrl } ) } } const validStripeKey = env.STRIPE_SECRET_KEY let stripeClient = null if (validStripeKey) { stripeClient = new Stripe(env.STRIPE_SECRET_KEY || '', { apiVersion: '2025-08-27.basil', }) } /** * Reverse-proxy hops trusted for forwarded-IP resolution. When configured, * Better Auth walks the x-forwarded-for chain right to left, skips these * hops, and records the first untrusted address as the session client IP — * preventing header spoofing behind multi-hop proxies. */ const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '') .split(',') .map((entry) => entry.trim()) .filter(Boolean) export const auth = betterAuth({ baseURL: getBaseUrl(), // Where Better Auth sends OAuth callbacks that fail before the flow state is // parsed — most commonly a provider-side Cancel/Deny. Without this it // defaults to a nonexistent `/error` (a 404 dead-end), which strands the // desktop sign-in/connect handoffs since their loopback is never pinged. onAPIError: { errorURL: `${getBaseUrl()}/oauth-error` }, trustedOrigins: [ getBaseUrl(), ...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []), ...additionalTrustedOrigins, ].filter(Boolean), database: (options: BetterAuthOptions) => guardSubscriptionPlanWrites( drizzleAdapter(db, { provider: 'pg', schema, })(options) ), session: { cookieCache: { enabled: true, // Better Auth's default, and deliberately short: the cached session is a // signed cookie that `getSession` returns WITHOUT re-reading the database, // so this is the window in which a revoked, expired, or signed-out session // still authenticates. Anything longer is an un-revocable credential — at // 24h a sign-out on one device left every other surface looking signed in // for a day while every database-backed check (socket handshakes, the // desktop handoff) failed against a row that no longer existed. The // `version` below only covers org-wide invalidation, so this TTL remains // the only bound on per-device sign-out latency. maxAge: 5 * 60, // 5 minutes in seconds /** * Embeds the member org's security-policy version. Bumping the version * (policy change, org-wide revocation) invalidates every cached session * cookie in the org on its next request, forcing a DB session read — * revocation latency becomes the policy cache TTL, not the full `maxAge`. */ version: async (session) => getSessionCookieCacheVersion(session as { userId?: string | null }), }, expiresIn: 30 * 24 * 60 * 60, // 30 days (how long a session can last overall) updateAge: 24 * 60 * 60, // 24 hours (how often to refresh the expiry) freshAge: 0, }, advanced: { ipAddress: { ...(trustedProxies.length > 0 ? { trustedProxies } : {}), }, }, user: { deleteUser: { enabled: false, beforeDelete: async (deletingUser) => { const { isSoleOwnerOfPaidOrganization } = await import( '@/lib/billing/organizations/membership' ) const check = await isSoleOwnerOfPaidOrganization(deletingUser.id) if (check.isBlocker) { throw new Error( `You are the owner of ${check.organizationName ?? 'an active paid organization'}. Transfer ownership before deleting your account.` ) } const { reassignBilledAccountForUser, reassignOwnedWorkspacesForUser } = await import( '@/lib/workspaces/utils' ) const { unresolved } = await reassignBilledAccountForUser(deletingUser.id) if (unresolved.length > 0) { throw new Error( `Your account is the billing account for ${unresolved.length} workspace${unresolved.length === 1 ? '' : 's'} with no other admin to take it over. Add another admin to ${unresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${unresolved.length === 1 ? 'it' : 'them'} before deleting your account.` ) } // Reassign workspace ownership BEFORE deletion so the `workspace.owner_id` // ON DELETE CASCADE can never silently nuke workspaces this user owns // (e.g. org workspaces they created but are billed to the org owner). const { unresolved: ownedUnresolved } = await reassignOwnedWorkspacesForUser( deletingUser.id ) if (ownedUnresolved.length > 0) { throw new Error( `Your account owns ${ownedUnresolved.length} workspace${ownedUnresolved.length === 1 ? '' : 's'} with no other admin to take over ownership. Add another admin to ${ownedUnresolved.length === 1 ? 'that workspace' : 'those workspaces'} or delete ${ownedUnresolved.length === 1 ? 'it' : 'them'} before deleting your account.` ) } }, }, }, databaseHooks: { user: { create: { before: async (user) => { const accessControl = await getAccessControlConfig() if (isEmailBlockedByAccessControl(user.email, accessControl)) { throw new Error('Sign-ups from this email are not allowed.') } return { data: user } }, after: async (user) => { logger.info('[databaseHooks.user.create.after] User created, initializing stats', { userId: user.id, }) try { PlatformEvents.userSignedUp({ userId: user.id, authMethod: 'email', }) } catch { // Telemetry should not fail the operation } try { const client = getPostHogClient() if (client) { client.identify({ distinctId: user.id, properties: { ...(user.email ? { email: user.email } : {}), ...(user.name ? { name: user.name } : {}), }, }) } } catch { // Telemetry should not fail the operation } try { await handleNewUser(user.id) } catch (error) { logger.error('[databaseHooks.user.create.after] Failed to initialize user stats', { userId: user.id, error, }) } /** * Places the user in the instance organization before they reach the * workspace list, so their first workspace is created org-owned and * org-scoped enterprise settings apply to it from the start. No-ops * unless `INSTANCE_ORG_NAME` is set, and swallows its own failures so * organization setup can never block a signup. */ await joinInstanceOrganization(user.id) if (isHosted && user.email && user.emailVerified) { try { const html = await renderWelcomeEmail(user.name || undefined) const { from, replyTo } = getPersonalEmailFrom() await sendEmail({ to: user.email, subject: getEmailSubject('welcome'), html, from, replyTo, emailType: 'transactional', }) logger.info('[databaseHooks.user.create.after] Welcome email sent to OAuth user', { userId: user.id, }) } catch (error) { logger.error('[databaseHooks.user.create.after] Failed to send welcome email', { userId: user.id, error, }) } try { await scheduleLifecycleEmail({ userId: user.id, type: 'onboarding-followup', delayDays: 5, }) } catch (error) { logger.error( '[databaseHooks.user.create.after] Failed to schedule onboarding followup email', { userId: user.id, error } ) } } }, }, update: { after: async (user) => { if (user.banned) { await disableUserResources(user.id) } }, }, }, account: { create: { before: async (account) => { const modifiedAccount = { ...account } if (account.providerId === 'salesforce' && account.accessToken) { try { const response = await fetch( 'https://login.salesforce.com/services/oauth2/userinfo', { headers: { Authorization: `Bearer ${account.accessToken}`, }, } ) if (response.ok) { const data = await response.json() if (data.profile) { const match = data.profile.match(/^(https:\/\/[^/]+)/) if (match && match[1] !== 'https://login.salesforce.com') { const instanceUrl = match[1] modifiedAccount.scope = `__sf_instance__:${instanceUrl} ${account.scope}` } } } } catch (error) { logger.error('Failed to fetch Salesforce instance URL', { error }) } } if (isMicrosoftProvider(account.providerId)) { modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() } // Box token response does not include a scope field, so Better Auth // stores nothing. Populate it from the requested scopes so the // credential-selector can verify permissions. if (account.providerId === 'box' && !account.scope) { const requestedScopes = getCanonicalScopesForProvider('box') if (requestedScopes.length > 0) { modifiedAccount.scope = requestedScopes.join(' ') } } return { data: modifiedAccount } }, after: async (account) => { /** * Migrate credentials from stale account rows to the newly created one. * * Each `getUserInfo` in `lib/auth/connectors/providers.ts` appends a * random UUID to the stable external ID so that Better Auth never * blocks cross-user connections — keep the two in step. This means * re-connecting the same external identity creates a new row. We detect * the stale siblings here by comparing the stable prefix (everything * before the trailing UUID), migrate any credential FKs to the new row, * then delete the stale rows. */ try { const UUID_SUFFIX_RE = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ const stablePrefix = account.accountId.replace(UUID_SUFFIX_RE, '') if (stablePrefix && stablePrefix !== account.accountId) { const siblings = await db .select({ id: schema.account.id, accountId: schema.account.accountId }) .from(schema.account) .where( and( eq(schema.account.userId, account.userId), eq(schema.account.providerId, account.providerId), sql`${schema.account.id} != ${account.id}` ) ) const staleRows = siblings.filter( (row) => row.accountId.replace(UUID_SUFFIX_RE, '') === stablePrefix ) if (staleRows.length > 0) { const staleIds = staleRows.map((row) => row.id) await db .update(schema.credential) .set({ accountId: account.id }) .where(inArray(schema.credential.accountId, staleIds)) await db.delete(schema.account).where(inArray(schema.account.id, staleIds)) logger.info('[account.create.after] Migrated credentials from stale accounts', { userId: account.userId, providerId: account.providerId, newAccountId: account.id, migratedFrom: staleIds, }) } } } catch (error) { logger.error('[account.create.after] Failed to clean up stale accounts', { userId: account.userId, providerId: account.providerId, error, }) } /** * A fresh Slack connect re-issues the installation's rotating token * chain, invalidating the copies held by sibling account rows for the * same team (Slack bot tokens are per-installation, not per-grant). * Propagate the new chain so every sibling is valid again, and clear * the installation's dead flag. */ if (account.providerId === 'slack' && account.accessToken) { try { const teamId = extractSlackTeamId(account.accountId) if (teamId) { // Clear the dead flag before fanning out: the connect itself // proves the installation has live tokens, and a fan-out // failure must not leave the hour-long flag blocking refreshes. await clearDeadFlag(`slack:${teamId}`) await fanOutSlackTokenChain(teamId, { accessToken: account.accessToken, refreshToken: account.refreshToken ?? null, accessTokenExpiresAt: account.accessTokenExpiresAt ?? null, }) logger.info('[account.create.after] Propagated Slack installation token chain', { userId: account.userId, teamId, newAccountId: account.id, }) } } catch (error) { logger.error('[account.create.after] Failed to propagate Slack token chain', { userId: account.userId, accountId: account.id, error, }) } } try { await processCredentialDraft({ userId: account.userId, providerId: account.providerId, accountId: account.id, }) } catch (error) { logger.error('[account.create.after] Failed to process credential draft', { userId: account.userId, providerId: account.providerId, error, }) } try { const { ensureUserStatsExists } = await import('@/lib/billing/core/usage') await ensureUserStatsExists(account.userId) } catch (error) { logger.error('[databaseHooks.account.create.after] Failed to ensure user stats', { userId: account.userId, accountId: account.id, error, }) } try { const [{ value: accountCount }] = await db .select({ value: count() }) .from(schema.account) .where(eq(schema.account.userId, account.userId)) if (accountCount === 1) { const { providerId } = account const authMethod = providerId === 'credential' ? 'email' : SSO_TRUSTED_PROVIDERS.includes(providerId) ? 'sso' : 'oauth' captureServerEvent( account.userId, 'user_created', { auth_method: authMethod, ...(providerId !== 'credential' ? { provider: providerId } : {}), }, { setOnce: { signup_at: new Date().toISOString() } } ) } } catch (error) { logger.error( '[databaseHooks.account.create.after] Failed to capture user_created event', { userId: account.userId, error, } ) } if (account.providerId === 'salesforce') { const updates: { accessTokenExpiresAt?: Date scope?: string } = {} if (!account.accessTokenExpiresAt) { updates.accessTokenExpiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000) } if (account.accessToken) { try { const response = await fetch( 'https://login.salesforce.com/services/oauth2/userinfo', { headers: { Authorization: `Bearer ${account.accessToken}`, }, } ) if (response.ok) { const data = await response.json() if (data.profile) { const match = data.profile.match(/^(https:\/\/[^/]+)/) if (match && match[1] !== 'https://login.salesforce.com') { const instanceUrl = match[1] updates.scope = `__sf_instance__:${instanceUrl} ${account.scope}` } } } } catch (error) { logger.error('Failed to fetch Salesforce instance URL', { error }) } } if (Object.keys(updates).length > 0) { await db.update(schema.account).set(updates).where(eq(schema.account.id, account.id)) } } if (isMicrosoftProvider(account.providerId)) { await db .update(schema.account) .set({ refreshTokenExpiresAt: getMicrosoftRefreshTokenExpiry() }) .where(eq(schema.account.id, account.id)) } try { PlatformEvents.oauthConnected({ userId: account.userId, provider: account.providerId, }) } catch { // Telemetry should not fail the operation } }, }, }, session: { create: { before: async (session) => { // Blocked emails/domains must not establish sessions, regardless of // provider (email/password, OAuth, SSO). Deliberately outside the // try below — a thrown APIError must propagate, not be swallowed. const accessControl = await getAccessControlConfig() if ( accessControl.blockedSignupDomains.length > 0 || accessControl.blockedEmails.length > 0 ) { const [sessionUser] = await db .select({ email: schema.user.email }) .from(schema.user) .where(eq(schema.user.id, session.userId)) .limit(1) if (isEmailBlockedByAccessControl(sessionUser?.email, accessControl)) { logger.warn('Blocking session creation for blocked account', { userId: session.userId, }) throw new APIError('FORBIDDEN', { message: 'Access restricted. Please contact your administrator.', }) } } try { // Find the first organization this user is a member of const members = await db .select({ organizationId: schema.member.organizationId }) .from(schema.member) .where(eq(schema.member.userId, session.userId)) .limit(1) if (members.length > 0) { logger.info('Found organization for user', { userId: session.userId, organizationId: members[0].organizationId, }) const expiresAt = await clampExpiryForSession(session, members[0].organizationId) return { data: { ...session, expiresAt, activeOrganizationId: members[0].organizationId, }, } } logger.info('No organizations found for user', { userId: session.userId, }) return { data: session } } catch (error) { logger.error('Error setting active organization', { error, userId: session.userId, }) return { data: session } } }, }, update: { /** * Better Auth's sliding refresh rewrites `expiresAt` to * `now + expiresIn` (30 days), which would silently stretch a * policy-shortened session back out — re-clamp on every refresh. * The current session row is read from the endpoint context; when * it is unavailable (non-refresh update paths) the update passes * through untouched and the next refresh re-clamps. */ before: async (data, ctx) => { if (!data.expiresAt) return { data } const current = ctx?.context?.session?.session if (!current) return { data } const expiresAt = await clampExpiryForSession({ ...current, expiresAt: new Date(data.expiresAt), }) return { data: { ...data, expiresAt } } }, }, }, }, account: { accountLinking: { enabled: true, allowDifferentEmails: true, requireLocalEmailVerified: false, /** * Only providers that verify email ownership may auto-link to an existing * account during sign-in. Integration connectors are deliberately absent: * they connect through the authenticated `/oauth2/link` flow, which binds * to the current session user and never consults this list. `microsoft` is * also excluded because it authenticates against the multi-tenant * `/common/` endpoint where the email claim is attacker-controllable; * leaving it trusted would bypass the email-verified check and allow * nOAuth account takeover. Microsoft sign-in still works — it just links * to an existing account only when the IdP asserts a verified email. */ trustedProviders: [ 'google', 'github', 'email-password', ...SSO_TRUSTED_PROVIDERS, ...additionalTrustedSsoProviders, ], }, }, socialProviders: { ...(!isGithubAuthDisabled && { github: { clientId: env.GITHUB_CLIENT_ID as string, clientSecret: env.GITHUB_CLIENT_SECRET as string, scope: ['user:email', 'repo'], }, }), ...(!isGoogleAuthDisabled && { google: { clientId: env.GOOGLE_CLIENT_ID as string, clientSecret: env.GOOGLE_CLIENT_SECRET as string, scope: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', ], }, }), ...(!isMicrosoftAuthDisabled && env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET && { microsoft: { clientId: env.MICROSOFT_CLIENT_ID, clientSecret: env.MICROSOFT_CLIENT_SECRET, scope: ['openid', 'profile', 'email'], }, }), }, emailVerification: { autoSignInAfterVerification: true, afterEmailVerification: async (user) => { if (isHosted && user.email) { try { const html = await renderWelcomeEmail(user.name || undefined) const { from, replyTo } = getPersonalEmailFrom() await sendEmail({ to: user.email, subject: getEmailSubject('welcome'), html, from, replyTo, emailType: 'transactional', }) logger.info('[emailVerification.afterEmailVerification] Welcome email sent', { userId: user.id, }) } catch (error) { logger.error('[emailVerification.afterEmailVerification] Failed to send welcome email', { userId: user.id, error, }) } try { await scheduleLifecycleEmail({ userId: user.id, type: 'onboarding-followup', delayDays: 5, }) } catch (error) { logger.error( '[emailVerification.afterEmailVerification] Failed to schedule onboarding followup email', { userId: user.id, error } ) } } }, }, emailAndPassword: { enabled: true, /** * Same flag that hides the email/password signup form (DISABLE_EMAIL_SIGNUP). * Blocks /sign-up/email at the better-auth layer so ripping out the frontend * form cannot be bypassed by calling the endpoint directly. Existing users * can still sign in. */ disableSignUp: isEmailSignupDisabled, requireEmailVerification: isEmailVerificationEffectivelyEnabled(), /** * When someone signs up with an already-registered email, better-auth returns a * generic success response (OWASP enumeration protection) instead of leaking that * the account exists. This callback notifies the real account owner out-of-band, * mirroring the privacy-preserving forget-password flow. Errors are swallowed so the * response is indistinguishable from a genuine new sign-up. */ onExistingUserSignUp: async ({ user }: { user: User }) => { try { const html = await renderExistingAccountEmail(user.name || '') const result = await sendEmail({ to: user.email, subject: getEmailSubject('existing-account'), html, from: getFromEmailAddress(), emailType: 'transactional', }) if (!result.success) { logger.warn('[onExistingUserSignUp] Failed to send existing-account email', { message: result.message, }) } } catch (error) { logger.error('[onExistingUserSignUp] Error sending existing-account email', { error }) } }, /** * The synthetic user returned for the generic duplicate-sign-up response must carry * the exact same set of returned fields a real freshly-created user would, otherwise * the differing response shape re-opens the enumeration oracle. The admin plugin * (always loaded) adds role/banned/banReason/banExpires, and the Stripe plugin — loaded * only when billing is enabled — adds stripeCustomerId (null on a new user). */ customSyntheticUser: ({ coreFields, additionalFields, id, }: { coreFields: { name: string email: string emailVerified: boolean image: string | null createdAt: Date updatedAt: Date } additionalFields: Record id: string }) => ({ ...coreFields, role: 'user', banned: false, banReason: null, banExpires: null, ...(isBillingEnabled && stripeClient ? { stripeCustomerId: null } : {}), ...additionalFields, id, }), sendResetPassword: async ({ user, url, token }, request) => { const username = user.name || '' const html = await renderPasswordResetEmail(username, url) const result = await sendEmail({ to: user.email, subject: getEmailSubject('reset-password'), html, from: getFromEmailAddress(), emailType: 'transactional', }) if (!result.success) { throw new Error(`Failed to send reset password email: ${result.message}`) } }, onPasswordReset: async ({ user: resetUser }) => { const { AuditAction, AuditResourceType, recordAudit } = await import('@sim/audit') recordAudit({ actorId: resetUser.id, actorName: resetUser.name, actorEmail: resetUser.email, action: AuditAction.PASSWORD_RESET, resourceType: AuditResourceType.PASSWORD, resourceId: resetUser.id, description: `Password reset completed for ${resetUser.email}`, }) }, }, hooks: { before: createAuthMiddleware(async (ctx) => { /** * Restrict the unauthenticated sign-in endpoints to first-party login * providers. Better Auth registers every generic-OAuth integration * connector as a social provider, so without this guard `microsoft-ad`, * `salesforce`, `jira`, and the rest are reachable through * `/sign-in/social` and `/sign-in/oauth2` and can mint a session for any * user by email (nOAuth account takeover). Connectors are connected only * through the authenticated `/oauth2/link` flow, which is unaffected. */ if (ctx.path === '/sign-in/social' || ctx.path === '/sign-in/oauth2') { const requestedProviderId = getRequestedSignInProviderId(ctx.path, ctx.body) if (!isSignInProviderAllowed(requestedProviderId)) { throw new APIError('FORBIDDEN', { message: 'This provider can only be connected from a signed-in account and cannot be used to sign in.', }) } } if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled) throw new APIError('FORBIDDEN', { message: 'Registration is disabled, please contact your admin.', }) if (!isEmailPasswordEnabled) { const emailPasswordPaths = ['/sign-in/email', '/sign-up/email', '/email-otp'] if (emailPasswordPaths.some((path) => ctx.path.startsWith(path))) throw new APIError('FORBIDDEN', { message: 'Email/password authentication is disabled. Please use SSO to sign in.', }) } const isSignIn = ctx.path.startsWith('/sign-in') const isSignUp = ctx.path.startsWith('/sign-up') if (isSignIn || isSignUp) { const accessControl = await getAccessControlConfig() const requestEmail = ctx.body?.email?.toLowerCase() // Banning an existing account is owned by better-auth's admin plugin (a // `session.create.before` hook that blocks banned users at sign-in across // all providers), so it is not re-checked here. const hasAllowlist = accessControl.allowedLoginEmails.length > 0 || accessControl.allowedLoginDomains.length > 0 if (hasAllowlist && requestEmail) { const emailDomain = requestEmail.split('@')[1] const isAllowed = accessControl.allowedLoginEmails.includes(requestEmail) || (!!emailDomain && accessControl.allowedLoginDomains.includes(emailDomain)) if (!isAllowed) { throw new APIError('FORBIDDEN', { message: 'Access restricted. Please contact your administrator.', }) } } // Blocked emails/domains gate both signup and sign-in. OAuth/SSO sign-ins // have no email in the body here; the session.create.before hook covers them. if (isEmailBlockedByAccessControl(requestEmail, accessControl)) { throw new APIError('FORBIDDEN', { message: isSignUp ? 'Sign-ups from this email are not allowed.' : 'Access restricted. Please contact your administrator.', }) } if ( isSignupMxValidationEnabled && ctx.path.startsWith('/sign-up/email') && ctx.body?.email ) { const mxCheck = await validateSignupEmailMx( ctx.body.email, accessControl.blockedEmailMxHosts ) if (!mxCheck.allowed) { throw new APIError('FORBIDDEN', { message: 'Sign-ups from this email domain are not allowed.', }) } } } /** * Personal checkout guard. The Stripe plugin's `authorizeReference` * only runs for organization references (it skips references equal to * the session user), so duplicate-coverage enforcement for personal * checkouts lives here: a member of an org with an entitled paid * subscription must not buy a personal plan on top of it. */ if (isBillingEnabled && ctx.path === '/subscription/upgrade') { const session = await getSessionFromCtx(ctx) const sessionUserId = session?.user?.id if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) { await assertPersonalCheckoutAllowed(sessionUserId) } } return }), }, plugins: [ ...(env.TURNSTILE_SECRET_KEY ? [ captcha({ provider: 'cloudflare-turnstile', secretKey: env.TURNSTILE_SECRET_KEY, endpoints: ['/sign-up/email'], }), ] : []), admin(), oneTimeToken({ expiresIn: 24 * 60, // 24 hours in minutes (better-auth's expiresIn unit) }), customSession(async ({ user, session }) => ({ user, session, })), emailOTP({ sendVerificationOTP: async (data) => { if (!isEmailVerificationEnabled) { logger.info('Skipping email verification') return } try { if (!data.email) { throw new Error('Email is required') } const validation = quickValidateEmail(data.email) if (!validation.isValid) { logger.warn('Email validation failed', { email: data.email, reason: validation.reason, checks: validation.checks, }) throw new Error( validation.reason || "We are unable to deliver the verification email to that address. Please make sure it's valid and able to receive emails." ) } const html = await renderOTPEmail(data.otp, data.email, data.type) const result = await sendEmail({ to: data.email, subject: getEmailSubject(data.type), html, from: getFromEmailAddress(), emailType: 'transactional', }) if (!result.success && result.message.includes('no email service configured')) { logger.info('🔑 VERIFICATION CODE FOR LOGIN/SIGNUP', { email: data.email, otp: data.otp, type: data.type, validation: validation.checks, }) return } if (!result.success) { throw new Error(`Failed to send verification code: ${result.message}`) } } catch (error) { logger.error('Error sending verification code:', { error, email: data.email, }) throw error } }, /** * Without this, /sign-in/email-otp auto-registers any unknown email — * bypassing the signup gate entirely (no captcha, no /sign-up path). * Gated by the same DISABLE_EMAIL_SIGNUP flag as the signup form (and by * DISABLE_REGISTRATION, whose /sign-up path check has the same blind * spot); when set, better-auth also silently skips sending OTPs to * unknown emails (enumeration-safe) while existing users keep OTP * sign-in. */ disableSignUp: isEmailSignupDisabled || isRegistrationDisabled, sendVerificationOnSignUp: false, otpLength: 6, // Explicitly set the OTP length expiresIn: 15 * 60, // 15 minutes in seconds overrideDefaultEmailVerification: true, }), genericOAuth({ config: buildConnectorProviders(), }), /** * Include SSO plugin when enabled. Resolved through `isSsoEnabled` rather * than the raw env var so the `ENTERPRISE_ENABLED` suite switch registers * the plugin too — reading `env.SSO_ENABLED` here would leave the settings * section visible and `hasSSOAccess` passing while sign-in silently had no * SSO provider behind it. */ ...(isSsoEnabled ? [ sso({ /** * Honor the IdP's `email_verified` claim so the local account is * verified rather than forced to false. * * This is not what enables linking — Entra omits the claim entirely, * and SAML ignores it without an explicit `mapping.emailVerified`. * `domainVerification` below establishes linking trust. */ trustEmailVerified: true, /** * Marks a provider authoritative for its domain, which is what lets an * SSO sign-in auto-link to an existing same-email account. Without it * `isTrustedProvider` is always false and every user who already had a * Sim account is stranded on "account not linked". * * Sim does not use Better Auth's DNS challenge endpoints: ownership is * proven by the `sso_domain` flow before registration, and the register * route mirrors that decision onto this flag. * * It narrows nothing on its own — an IdP asserting `email_verified` * links regardless of domain (see `trustEmailVerified` above). It * exists so linking survives IdPs that omit the claim. */ domainVerification: { enabled: true }, organizationProvisioning: { disabled: false, defaultRole: 'member', }, }), ] : []), // Only include the Stripe plugin when billing is enabled ...(isBillingEnabled && stripeClient ? [ stripe({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET || '', createCustomerOnSignUp: true, onCustomerCreate: async ({ stripeCustomer, user }) => { logger.info('[onCustomerCreate] Stripe customer created', { stripeCustomerId: stripeCustomer.id, userId: user.id, }) }, subscription: { enabled: true, plans: getPlans(), authorizeReference: async ({ user, referenceId, action }, ctx) => { const body: unknown = ctx?.body const requestedPlan = typeof body === 'object' && body !== null && 'plan' in body && typeof body.plan === 'string' ? body.plan : undefined return await authorizeSubscriptionReference( user.id, referenceId, action, requestedPlan ) }, getCheckoutSessionParams: async () => ({ params: { allow_promotion_codes: true }, }), onSubscriptionComplete: async ({ event, stripeSubscription, subscription, }: { event: Stripe.Event stripeSubscription: Stripe.Subscription subscription: any }) => { const { priceId, planFromStripe, isAnnual } = resolvePlanFromStripeSubscription(stripeSubscription) logger.info('[onSubscriptionComplete] Subscription created', { subscriptionId: subscription.id, referenceId: subscription.referenceId, dbPlan: subscription.plan, planFromStripe, priceId, isAnnual, status: subscription.status, }) if (!planFromStripe) { logger.error( '[onSubscriptionComplete] Could not resolve plan from Stripe price — check env var configuration', { subscriptionId: subscription.id, dbPlan: subscription.plan, priceId } ) } const syncedPlan = await syncSubscriptionPlan( subscription.id, subscription.plan, planFromStripe, subscription.referenceId ) const subscriptionForOrg = { ...subscription, plan: syncedPlan ?? subscription.plan, enterpriseOperationId: stripeSubscription.metadata?.enterpriseOperationId ?? null, } let resolvedSubscription = subscription try { resolvedSubscription = await ensureOrganizationForTeamSubscription(subscriptionForOrg) } catch (orgError) { logger.error( '[onSubscriptionComplete] Failed to ensure organization for team subscription', { subscriptionId: subscription.id, referenceId: subscription.referenceId, dbPlan: subscription.plan, planFromStripe, error: toError(orgError).message, stack: orgError instanceof Error ? orgError.stack : undefined, } ) throw orgError } /** * Transactional fence behind the personal-checkout admission * guard: if the user joined a paid organization while their * checkout was in flight, pause the fresh personal Pro at * period end (same state a paid-org joiner's personal Pro * enters; restored automatically if they leave the org). * * Runs BEFORE the free→paid transition handling: a personal * subscription born covered is not a free→paid transition — * the org plan keeps governing the user — so the usage reset * (which would wipe org-attributed current-period usage) and * its instrumentation must not run. Gated on `covered`, not * `paused`, so event retries decide identically even when the * join path already paused the subscription. */ const coveredByOrganization = isPro(resolvedSubscription.plan) ? (await pauseProSubscriptionForOrgCoverage(resolvedSubscription.referenceId)) .covered : false if (!coveredByOrganization) { await handleSubscriptionCreated(resolvedSubscription, event.id) } await syncSubscriptionUsageLimits(resolvedSubscription) await writeBillingInterval(resolvedSubscription.id, isAnnual ? 'year' : 'month') await sendPlanWelcomeEmail(resolvedSubscription) }, onSubscriptionUpdate: async ({ event, subscription, }: { event: Stripe.Event subscription: any }) => { const stripeSubscription = event.data.object as Stripe.Subscription const { priceId, planFromStripe, isTeamPlan, isAnnual } = resolvePlanFromStripeSubscription(stripeSubscription) if (priceId && !planFromStripe) { logger.warn( '[onSubscriptionUpdate] Could not determine plan from Stripe price ID', { subscriptionId: subscription.id, priceId, dbPlan: subscription.plan, } ) } const referenceOrganizationId = await getOrganizationIdForSubscriptionReference( subscription.referenceId ) const isUpgradeToTeam = isTeamPlan && !isTeam(subscription.plan) && referenceOrganizationId == null logger.info('[onSubscriptionUpdate] Subscription updated', { subscriptionId: subscription.id, status: subscription.status, dbPlan: subscription.plan, planFromStripe, isUpgradeToTeam, isAnnual, referenceId: subscription.referenceId, referenceOrganizationId, }) if (!planFromStripe) { logger.error( '[onSubscriptionUpdate] Could not resolve plan from Stripe price — org creation may be skipped for team upgrades', { subscriptionId: subscription.id, dbPlan: subscription.plan } ) } const syncedPlan = await syncSubscriptionPlan( subscription.id, subscription.plan, planFromStripe, subscription.referenceId ) /** * All downstream processing keys off the plan the DB actually * holds after the sync — a plan write refused by the org/plan * invariant must not leak the rejected Stripe plan into org * resolution, seat sync, or usage limits. */ const effectivePlanForTeamFeatures = syncedPlan ?? subscription.plan const subscriptionForOrg = { ...subscription, plan: effectivePlanForTeamFeatures, enterpriseOperationId: stripeSubscription.metadata?.enterpriseOperationId ?? null, } let resolvedSubscription = subscription try { resolvedSubscription = await ensureOrganizationForTeamSubscription(subscriptionForOrg) if (isUpgradeToTeam) { logger.info( '[onSubscriptionUpdate] Detected Pro -> Team upgrade, ensured organization creation', { subscriptionId: subscription.id, originalPlan: subscription.plan, newPlan: planFromStripe, resolvedReferenceId: resolvedSubscription.referenceId, } ) } } catch (orgError) { logger.error( '[onSubscriptionUpdate] Failed to ensure organization for team subscription', { subscriptionId: subscription.id, referenceId: subscription.referenceId, dbPlan: subscription.plan, planFromStripe, isUpgradeToTeam, error: toError(orgError).message, stack: orgError instanceof Error ? orgError.stack : undefined, } ) throw orgError } try { await syncSubscriptionUsageLimits(resolvedSubscription) } catch (error) { logger.error('[onSubscriptionUpdate] Failed to sync usage limits', { subscriptionId: resolvedSubscription.id, referenceId: resolvedSubscription.referenceId, error, }) } if (isTeam(effectivePlanForTeamFeatures)) { try { const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1 const result = await syncSeatsFromStripeQuantity( resolvedSubscription.id, resolvedSubscription.seats ?? null, quantity ) if (result.synced) { logger.info('[onSubscriptionUpdate] Synced seat count from Stripe', { subscriptionId: resolvedSubscription.id, referenceId: resolvedSubscription.referenceId, previousSeats: result.previousSeats, newSeats: result.newSeats, }) } } catch (error) { logger.error('[onSubscriptionUpdate] Failed to sync seat count', { subscriptionId: resolvedSubscription.id, referenceId: resolvedSubscription.referenceId, error, }) } } await writeBillingInterval(resolvedSubscription.id, isAnnual ? 'year' : 'month') }, onSubscriptionDeleted: async ({ event, subscription, }: { event: Stripe.Event stripeSubscription: Stripe.Subscription subscription: any }) => { logger.info('[onSubscriptionDeleted] Subscription deleted', { eventId: event.id, subscriptionId: subscription.id, referenceId: subscription.referenceId, }) try { await handleSubscriptionDeleted(subscription, event.id) } catch (error) { logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', { eventId: event.id, subscriptionId: subscription.id, referenceId: subscription.referenceId, error, }) // Rethrow so the Stripe webhook retries — otherwise // the final overage invoice, usage reset, org cleanup, // and personal Pro restore can be permanently skipped. throw error } }, }, onEvent: async (event: Stripe.Event) => { logger.info('[onEvent] Received Stripe webhook', { eventId: event.id, eventType: event.type, }) try { switch (event.type) { case 'invoice.payment_succeeded': { await handleInvoicePaymentSucceeded(event) break } case 'invoice.payment_failed': { await handleInvoicePaymentFailed(event) break } case 'invoice.finalized': { await handleInvoiceFinalized(event) break } case 'customer.subscription.created': case 'customer.subscription.updated': { await handleManualEnterpriseSubscription(event) break } case 'checkout.session.expired': { await handleAbandonedCheckout(event) break } case 'charge.dispute.created': { await handleChargeDispute(event) break } case 'charge.dispute.closed': { await handleDisputeClosed(event) break } default: logger.info('[onEvent] Ignoring unsupported webhook event', { eventId: event.id, eventType: event.type, }) break } logger.info('[onEvent] Successfully processed webhook', { eventId: event.id, eventType: event.type, }) } catch (error) { logger.error('[onEvent] Failed to process webhook', { eventId: event.id, eventType: event.type, error, }) throw error } }, }), ] : []), ...(isOrganizationsEnabled ? [ organization({ allowUserToCreateOrganization: async () => false, disableOrganizationDeletion: true, requireEmailVerificationOnInvitation: isEmailVerificationEffectivelyEnabled(), organizationHooks: { afterCreateOrganization: async ({ organization, user }) => { logger.info('[organizationHooks.afterCreateOrganization] Organization created', { organizationId: organization.id, creatorId: user.id, }) }, }, }), ] : []), nextCookies(), ], }) async function getSessionImpl() { if (isAuthDisabled) { await ensureAnonymousUserExists() return createAnonymousSession() } const hdrs = await headers() return await auth.api.getSession({ headers: hdrs, }) } export const getSession = cache(getSessionImpl)