6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
1510 lines
58 KiB
TypeScript
1510 lines
58 KiB
TypeScript
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<string, unknown>
|
|
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)
|