d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
3387 lines
129 KiB
TypeScript
3387 lines
129 KiB
TypeScript
import { createHash } from 'crypto'
|
|
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 { generateId } from '@sim/utils/id'
|
|
import { betterAuth, type User } from 'better-auth'
|
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
import { APIError, createAuthMiddleware } 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 { sendPlanWelcomeEmail } from '@/lib/billing'
|
|
import { authorizeSubscriptionReference } 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 { 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 {
|
|
handleOrganizationPlanDowngrade,
|
|
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 { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
|
|
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
|
|
import { disableUserResources } from '@/lib/workflows/lifecycle'
|
|
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
|
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
|
import { getRequestedSignInProviderId, isSignInProviderAllowed } from './constants'
|
|
|
|
const logger = createLogger('Auth')
|
|
|
|
import {
|
|
deriveMicrosoftEmailVerified,
|
|
getMicrosoftRefreshTokenExpiry,
|
|
isMicrosoftProvider,
|
|
} from '@/lib/oauth/microsoft'
|
|
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
|
|
|
/**
|
|
* Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me.
|
|
* This avoids 403 errors for external tenant users whose admin hasn't consented to Graph API scopes.
|
|
* The ID token is always returned when the openid scope is requested.
|
|
*/
|
|
function getMicrosoftUserInfoFromIdToken(tokens: { accessToken?: string }, providerId: string) {
|
|
const idToken = (tokens as Record<string, unknown>).idToken as string | undefined
|
|
if (!idToken) {
|
|
logger.error(
|
|
`Microsoft ${providerId} OAuth: no ID token received. Ensure openid scope is requested.`
|
|
)
|
|
throw new Error(`Microsoft ${providerId} OAuth requires an ID token (openid scope)`)
|
|
}
|
|
|
|
const parts = idToken.split('.')
|
|
if (parts.length !== 3) {
|
|
throw new Error(`Microsoft ${providerId} OAuth: malformed ID token`)
|
|
}
|
|
|
|
let payload: Record<string, unknown>
|
|
try {
|
|
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))
|
|
} catch {
|
|
throw new Error(`Microsoft ${providerId} OAuth: failed to decode ID token payload`)
|
|
}
|
|
|
|
const email =
|
|
(payload.email as string) || (payload.preferred_username as string) || (payload.upn as string)
|
|
if (!email) {
|
|
throw new Error(
|
|
`Microsoft ${providerId} OAuth: ID token contains no email, preferred_username, or upn claim`
|
|
)
|
|
}
|
|
|
|
const emailVerified = deriveMicrosoftEmailVerified(payload, email)
|
|
|
|
const now = new Date()
|
|
return {
|
|
id: `${payload.oid || payload.sub}-${generateId()}`,
|
|
name: (payload.name as string) || 'Microsoft User',
|
|
email,
|
|
emailVerified,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
}
|
|
|
|
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
|
|
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
|
|
)
|
|
|
|
/**
|
|
* SSO provider IDs to trust for automatic account linking when an SSO sign-in
|
|
* matches an existing account's email. Includes `SSO_PROVIDER_ID` when it is set
|
|
* in the app environment, plus any IDs from `SSO_TRUSTED_PROVIDER_IDS`. Empty when
|
|
* SSO is disabled, so `trustedProviders` is unchanged for non-SSO deployments.
|
|
* Resolved once at startup; `trustEmailVerified` on the SSO plugin handles IdPs
|
|
* that assert `email_verified` live, so this is only needed for IdPs that omit it.
|
|
*/
|
|
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',
|
|
})
|
|
}
|
|
|
|
export const auth = betterAuth({
|
|
baseURL: getBaseUrl(),
|
|
trustedOrigins: [
|
|
getBaseUrl(),
|
|
...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []),
|
|
...additionalTrustedOrigins,
|
|
].filter(Boolean),
|
|
database: drizzleAdapter(db, {
|
|
provider: 'pg',
|
|
schema,
|
|
}),
|
|
session: {
|
|
cookieCache: {
|
|
enabled: true,
|
|
maxAge: 24 * 60 * 60, // 24 hours in seconds
|
|
},
|
|
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,
|
|
},
|
|
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,
|
|
})
|
|
}
|
|
|
|
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 appends a random UUID to the stable external ID so
|
|
* that Better Auth never blocks cross-user connections. 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,
|
|
})
|
|
}
|
|
|
|
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()
|
|
.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,
|
|
})
|
|
|
|
return {
|
|
data: {
|
|
...session,
|
|
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 }
|
|
}
|
|
},
|
|
},
|
|
},
|
|
},
|
|
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,
|
|
requireEmailVerification: isEmailVerificationEnabled,
|
|
/**
|
|
* 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.',
|
|
})
|
|
}
|
|
|
|
if (isEmailSignupDisabled && ctx.path.startsWith('/sign-up/email'))
|
|
throw new APIError('FORBIDDEN', {
|
|
message: 'Email sign-up is disabled. Please use Google, Microsoft, or GitHub.',
|
|
})
|
|
|
|
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.',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
},
|
|
sendVerificationOnSignUp: false,
|
|
otpLength: 6, // Explicitly set the OTP length
|
|
expiresIn: 15 * 60, // 15 minutes in seconds
|
|
overrideDefaultEmailVerification: true,
|
|
}),
|
|
genericOAuth({
|
|
config: [
|
|
{
|
|
providerId: 'google-email',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-email'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-calendar',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-calendar'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-drive',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-drive'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-docs',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-docs'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-sheets',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-sheets'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'google-contacts',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-contacts'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-forms',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-forms'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-ads',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-ads'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-bigquery',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-bigquery'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'google-vault',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-vault'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'google-groups',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-groups'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'google-meet',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-meet'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'google-tasks',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('google-tasks'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'vertex-ai',
|
|
clientId: env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
|
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('vertex-ai'),
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Google user info', { status: response.status })
|
|
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
|
}
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'Google User',
|
|
email: profile.email,
|
|
image: profile.picture || undefined,
|
|
emailVerified: profile.email_verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Google getUserInfo', { error })
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'microsoft-ad',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('microsoft-ad'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-ad`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-ad')
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'microsoft-teams',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('microsoft-teams'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-teams`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-teams')
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'microsoft-excel',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('microsoft-excel'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-excel`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-excel')
|
|
},
|
|
},
|
|
{
|
|
providerId: 'microsoft-dataverse',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('microsoft-dataverse'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-dataverse`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-dataverse')
|
|
},
|
|
},
|
|
{
|
|
providerId: 'microsoft-planner',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('microsoft-planner'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/microsoft-planner`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'microsoft-planner')
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'outlook',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('outlook'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/outlook`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'outlook')
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'onedrive',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('onedrive'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/onedrive`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'onedrive')
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'sharepoint',
|
|
clientId: env.MICROSOFT_CLIENT_ID as string,
|
|
clientSecret: env.MICROSOFT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
scopes: getCanonicalScopesForProvider('sharepoint'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
pkce: true,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/sharepoint`,
|
|
getUserInfo: async (tokens) => {
|
|
return getMicrosoftUserInfoFromIdToken(tokens, 'sharepoint')
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'wealthbox',
|
|
clientId: env.WEALTHBOX_CLIENT_ID as string,
|
|
clientSecret: env.WEALTHBOX_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize',
|
|
tokenUrl: 'https://app.crmworkspace.com/oauth/token',
|
|
userInfoUrl: 'https://api.crmworkspace.com/v1/me',
|
|
scopes: getCanonicalScopesForProvider('wealthbox'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching Wealthbox user profile')
|
|
|
|
const response = await fetch('https://api.crmworkspace.com/v1/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
const now = new Date()
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
const userId = data.id?.toString()
|
|
if (!userId) {
|
|
return null
|
|
}
|
|
const email =
|
|
data.email && typeof data.email === 'string'
|
|
? data.email
|
|
: `wealthbox-${userId}@wealthbox.user`
|
|
const name = data.name || data.full_name || data.username || 'Wealthbox User'
|
|
|
|
return {
|
|
id: `wealthbox-${userId}-${generateId()}`,
|
|
name,
|
|
email,
|
|
emailVerified: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
}
|
|
|
|
// Fallback: derive a stable identifier from the refresh token (long-lived)
|
|
// rather than the access token (rotates every ~2 hours) to avoid creating
|
|
// duplicate accounts on token refresh.
|
|
logger.warn(
|
|
'Wealthbox user info fetch failed, falling back to token-derived identity',
|
|
{
|
|
status: response.status,
|
|
}
|
|
)
|
|
const stableToken = tokens.refreshToken ?? tokens.accessToken
|
|
if (!stableToken) {
|
|
logger.error('Wealthbox fallback identity: no refresh or access token available')
|
|
return null
|
|
}
|
|
const tokenHash = createHash('sha256').update(stableToken).digest('hex').slice(0, 24)
|
|
return {
|
|
id: `wealthbox-${tokenHash}-${generateId()}`,
|
|
name: 'Wealthbox User',
|
|
email: `wealthbox-${tokenHash}@wealthbox.user`,
|
|
emailVerified: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error creating Wealthbox user profile:', {
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'pipedrive',
|
|
clientId: env.PIPEDRIVE_CLIENT_ID as string,
|
|
clientSecret: env.PIPEDRIVE_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://oauth.pipedrive.com/oauth/authorize',
|
|
tokenUrl: 'https://oauth.pipedrive.com/oauth/token',
|
|
userInfoUrl: 'https://api.pipedrive.com/v1/users/me',
|
|
prompt: 'consent',
|
|
scopes: getCanonicalScopesForProvider('pipedrive'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching Pipedrive user profile')
|
|
|
|
const response = await fetch('https://api.pipedrive.com/v1/users/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Pipedrive user info', {
|
|
status: response.status,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const data = await response.json()
|
|
const user = data.data
|
|
|
|
return {
|
|
id: `${user.id.toString()}-${generateId()}`,
|
|
name: user.name,
|
|
email: user.email,
|
|
emailVerified: user.activated,
|
|
image: user.icon_url,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error creating Pipedrive user profile:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'hubspot',
|
|
clientId: env.HUBSPOT_CLIENT_ID as string,
|
|
clientSecret: env.HUBSPOT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://app.hubspot.com/oauth/authorize',
|
|
tokenUrl: 'https://api.hubapi.com/oauth/v1/token',
|
|
userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens',
|
|
prompt: 'consent',
|
|
scopes: getCanonicalScopesForProvider('hubspot'),
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching HubSpot user profile')
|
|
|
|
const response = await fetch(
|
|
`https://api.hubapi.com/oauth/v1/access-tokens/${tokens.accessToken}`
|
|
)
|
|
|
|
if (!response.ok) {
|
|
let errorBody: string | undefined
|
|
try {
|
|
errorBody = await response.text()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
logger.error('Failed to fetch HubSpot user info', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body: errorBody?.slice(0, 500),
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const rawText = await response.text()
|
|
const data = JSON.parse(rawText)
|
|
|
|
const scopesArray = Array.isArray((data as any)?.scopes) ? (data as any).scopes : []
|
|
if (Array.isArray(scopesArray) && scopesArray.length > 0) {
|
|
tokens.scopes = scopesArray
|
|
} else if (typeof (data as any)?.scope === 'string') {
|
|
tokens.scopes = (data as any).scope.split(/\s+/).filter(Boolean)
|
|
}
|
|
|
|
logger.info('HubSpot token metadata response:', {
|
|
hubId: data.hub_id,
|
|
hubDomain: data.hub_domain,
|
|
userId: data.user_id,
|
|
hasScopes: !!data.scopes,
|
|
scopesType: typeof data.scopes,
|
|
scopesIsArray: Array.isArray(data.scopes),
|
|
})
|
|
|
|
return {
|
|
id: `${(data.user_id || data.hub_id).toString()}-${generateId()}`,
|
|
name: data.user || 'HubSpot User',
|
|
email: data.user || `hubspot-${data.hub_id}@hubspot.com`,
|
|
emailVerified: true,
|
|
image: undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
// Extract scopes from HubSpot's response and convert array to space-delimited string
|
|
// Use 'scope' (singular) as that's what better-auth expects for the account table
|
|
...(data.scopes && Array.isArray(data.scopes)
|
|
? { scope: data.scopes.join(' ') }
|
|
: {}),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error creating HubSpot user profile:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'salesforce',
|
|
clientId: env.SALESFORCE_CLIENT_ID as string,
|
|
clientSecret: env.SALESFORCE_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize',
|
|
tokenUrl: 'https://login.salesforce.com/services/oauth2/token',
|
|
userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo',
|
|
scopes: getCanonicalScopesForProvider('salesforce'),
|
|
pkce: true,
|
|
prompt: 'consent',
|
|
accessType: 'offline',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch(
|
|
'https://login.salesforce.com/services/oauth2/userinfo',
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Salesforce user info', {
|
|
status: response.status,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
return {
|
|
id: `${(data.user_id || data.sub).toString()}-${generateId()}`,
|
|
name: data.name || 'Salesforce User',
|
|
email: data.email || `salesforce-${data.user_id}@salesforce.com`,
|
|
emailVerified: data.email_verified === true,
|
|
image: data.picture || undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error creating Salesforce user profile:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'x',
|
|
clientId: env.X_CLIENT_ID as string,
|
|
clientSecret: env.X_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://x.com/i/oauth2/authorize',
|
|
tokenUrl: 'https://api.x.com/2/oauth2/token',
|
|
userInfoUrl: 'https://api.x.com/2/users/me',
|
|
accessType: 'offline',
|
|
scopes: getCanonicalScopesForProvider('x'),
|
|
pkce: true,
|
|
responseType: 'code',
|
|
prompt: 'consent',
|
|
authentication: 'basic',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/x`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch(
|
|
'https://api.x.com/2/users/me?user.fields=profile_image_url,username,name,verified',
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching X user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const profile = await response.json()
|
|
|
|
if (!profile.data) {
|
|
logger.error('Invalid X profile response:', profile)
|
|
return null
|
|
}
|
|
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${profile.data.id.toString()}-${generateId()}`,
|
|
name: profile.data.name || 'X User',
|
|
email: `${profile.data.username}@x.com`,
|
|
image: profile.data.profile_image_url,
|
|
emailVerified: profile.data.verified || false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in X getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'tiktok',
|
|
clientId: env.TIKTOK_CLIENT_ID as string,
|
|
clientSecret: env.TIKTOK_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/',
|
|
tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/',
|
|
scopes: getCanonicalScopesForProvider('tiktok'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`,
|
|
// TikTok requires the app identifier under `client_key`, not the standard
|
|
// `client_id`. The library always sends `client_id` too, but TikTok ignores
|
|
// unrecognized params, so adding `client_key` here (for both the authorize
|
|
// redirect and the token exchange) is sufficient without patching the library.
|
|
//
|
|
// TikTok also requires a comma-separated `scope` list, but the library always
|
|
// joins scopes with a space (no configurable separator in the generic-oauth
|
|
// plugin's authorize route). `additionalParams` is applied via
|
|
// `url.searchParams.set(key, value)` after the default scope is set, so
|
|
// overriding `scope` here replaces the space-joined value with a comma-joined
|
|
// one that TikTok can actually parse.
|
|
authorizationUrlParams: {
|
|
client_key: env.TIKTOK_CLIENT_ID as string,
|
|
scope: getCanonicalScopesForProvider('tiktok').join(','),
|
|
},
|
|
tokenUrlParams: { client_key: env.TIKTOK_CLIENT_ID as string },
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch(
|
|
'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url',
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching TikTok user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const profile = await response.json()
|
|
const user = profile.data?.user
|
|
|
|
if (!user?.open_id) {
|
|
logger.error('Invalid TikTok profile response:', profile)
|
|
return null
|
|
}
|
|
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${user.open_id}-${generateId()}`,
|
|
name: user.display_name || 'TikTok User',
|
|
email: `${user.open_id}@tiktok.user`,
|
|
image: user.avatar_url || undefined,
|
|
emailVerified: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in TikTok getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'confluence',
|
|
clientId: env.CONFLUENCE_CLIENT_ID as string,
|
|
clientSecret: env.CONFLUENCE_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://auth.atlassian.com/authorize',
|
|
tokenUrl: 'https://auth.atlassian.com/oauth/token',
|
|
userInfoUrl: 'https://api.atlassian.com/me',
|
|
scopes: getCanonicalScopesForProvider('confluence'),
|
|
responseType: 'code',
|
|
pkce: true,
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/confluence`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.atlassian.com/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Confluence user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const profile = await response.json()
|
|
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${profile.account_id.toString()}-${generateId()}`,
|
|
name: profile.name || profile.display_name || 'Confluence User',
|
|
email: profile.email || `${profile.account_id}@atlassian.com`,
|
|
image: profile.picture || undefined,
|
|
emailVerified: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Confluence getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'jira',
|
|
clientId: env.JIRA_CLIENT_ID as string,
|
|
clientSecret: env.JIRA_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://auth.atlassian.com/authorize',
|
|
tokenUrl: 'https://auth.atlassian.com/oauth/token',
|
|
userInfoUrl: 'https://api.atlassian.com/me',
|
|
scopes: getCanonicalScopesForProvider('jira'),
|
|
responseType: 'code',
|
|
pkce: true,
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/jira`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.atlassian.com/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Jira user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const profile = await response.json()
|
|
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${profile.account_id.toString()}-${generateId()}`,
|
|
name: profile.name || profile.display_name || 'Jira User',
|
|
email: profile.email || `${profile.account_id}@atlassian.com`,
|
|
image: profile.picture || undefined,
|
|
emailVerified: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Jira getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'airtable',
|
|
clientId: env.AIRTABLE_CLIENT_ID as string,
|
|
clientSecret: env.AIRTABLE_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://airtable.com/oauth2/v1/authorize',
|
|
tokenUrl: 'https://airtable.com/oauth2/v1/token',
|
|
userInfoUrl: 'https://api.airtable.com/v0/meta/whoami',
|
|
scopes: getCanonicalScopesForProvider('airtable'),
|
|
responseType: 'code',
|
|
pkce: true,
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/airtable`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.airtable.com/v0/meta/whoami', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Airtable user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const data = await response.json()
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${data.id.toString()}-${generateId()}`,
|
|
name: data.email ? data.email.split('@')[0] : 'Airtable User',
|
|
email: data.email || `${data.id}@airtable.user`,
|
|
emailVerified: !!data.email,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Airtable getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'notion',
|
|
clientId: env.NOTION_CLIENT_ID as string,
|
|
clientSecret: env.NOTION_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://api.notion.com/v1/oauth/authorize',
|
|
tokenUrl: 'https://api.notion.com/v1/oauth/token',
|
|
userInfoUrl: 'https://api.notion.com/v1/users/me',
|
|
responseType: 'code',
|
|
pkce: false,
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/notion`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.notion.com/v1/users/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
'Notion-Version': '2022-06-28',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Notion user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const profile = await response.json()
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${(profile.bot?.owner?.user?.id || profile.id).toString()}-${generateId()}`,
|
|
name: profile.name || profile.bot?.owner?.user?.name || 'Notion User',
|
|
email: profile.person?.email || `${profile.id}@notion.user`,
|
|
emailVerified: !!profile.person?.email,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Notion getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'monday',
|
|
clientId: env.MONDAY_CLIENT_ID as string,
|
|
clientSecret: env.MONDAY_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://auth.monday.com/oauth2/authorize',
|
|
tokenUrl: 'https://auth.monday.com/oauth2/token',
|
|
userInfoUrl: 'https://api.monday.com/v2',
|
|
scopes: getCanonicalScopesForProvider('monday'),
|
|
responseType: 'code',
|
|
pkce: false,
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/monday`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.monday.com/v2', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'API-Version': '2024-10',
|
|
Authorization: tokens.accessToken ?? '',
|
|
},
|
|
body: JSON.stringify({ query: '{ me { id name email } }' }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Monday.com user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const data = await response.json()
|
|
const user = data.data?.me
|
|
if (!user) return null
|
|
|
|
const now = new Date()
|
|
return {
|
|
id: `${user.id.toString()}-${generateId()}`,
|
|
name: user.name || 'Monday.com User',
|
|
email: user.email || `${user.id}@monday.user`,
|
|
emailVerified: !!user.email,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Monday.com getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'reddit',
|
|
clientId: env.REDDIT_CLIENT_ID as string,
|
|
clientSecret: env.REDDIT_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://www.reddit.com/api/v1/authorize?duration=permanent',
|
|
tokenUrl: 'https://www.reddit.com/api/v1/access_token',
|
|
userInfoUrl: 'https://oauth.reddit.com/api/v1/me',
|
|
scopes: getCanonicalScopesForProvider('reddit'),
|
|
responseType: 'code',
|
|
pkce: false,
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/reddit`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://oauth.reddit.com/api/v1/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
'User-Agent': 'sim-studio/1.0',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Reddit user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const data = await response.json()
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${data.id.toString()}-${generateId()}`,
|
|
name: data.name || 'Reddit User',
|
|
email: `${data.name}@reddit.user`,
|
|
image: data.icon_img || undefined,
|
|
emailVerified: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Reddit getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'linear',
|
|
clientId: env.LINEAR_CLIENT_ID as string,
|
|
clientSecret: env.LINEAR_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://linear.app/oauth/authorize',
|
|
tokenUrl: 'https://api.linear.app/oauth/token',
|
|
scopes: getCanonicalScopesForProvider('linear'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`,
|
|
pkce: true,
|
|
prompt: 'consent',
|
|
accessType: 'offline',
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.linear.app/graphql', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
body: JSON.stringify({
|
|
query: `{
|
|
viewer {
|
|
id
|
|
email
|
|
name
|
|
avatarUrl
|
|
}
|
|
}`,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
logger.error('Linear API error:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body: errorText,
|
|
})
|
|
throw new Error(`Linear API error: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
const { data, errors } = await response.json()
|
|
|
|
if (errors) {
|
|
logger.error('GraphQL errors:', errors)
|
|
throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`)
|
|
}
|
|
|
|
if (!data?.viewer) {
|
|
logger.error('No viewer data in response:', data)
|
|
throw new Error('No viewer data in response')
|
|
}
|
|
|
|
const viewer = data.viewer
|
|
|
|
return {
|
|
id: `${viewer.id.toString()}-${generateId()}`,
|
|
email: viewer.email,
|
|
name: viewer.name,
|
|
emailVerified: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
image: viewer.avatarUrl || undefined,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in getUserInfo:', error)
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'attio',
|
|
clientId: env.ATTIO_CLIENT_ID as string,
|
|
clientSecret: env.ATTIO_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://app.attio.com/authorize',
|
|
tokenUrl: 'https://app.attio.com/oauth/token',
|
|
scopes: getCanonicalScopesForProvider('attio'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.attio.com/v2/workspace_members', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
logger.error('Attio API error:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body: errorText,
|
|
})
|
|
throw new Error(`Attio API error: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
const { data } = await response.json()
|
|
|
|
if (!data || data.length === 0) {
|
|
throw new Error('No workspace members found in Attio response')
|
|
}
|
|
|
|
const member = data[0]
|
|
|
|
return {
|
|
id: `${member.id.workspace_member_id}-${generateId()}`,
|
|
email: member.email_address,
|
|
name:
|
|
`${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() ||
|
|
member.email_address,
|
|
emailVerified: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
image: member.avatar_url || undefined,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Attio getUserInfo:', error)
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'box',
|
|
clientId: env.BOX_CLIENT_ID as string,
|
|
clientSecret: env.BOX_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://account.box.com/api/oauth2/authorize',
|
|
tokenUrl: 'https://api.box.com/oauth2/token',
|
|
scopes: getCanonicalScopesForProvider('box'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/box`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://api.box.com/2.0/users/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
logger.error('Box API error:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body: errorText,
|
|
})
|
|
throw new Error(`Box API error: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
return {
|
|
id: `${data.id}-${generateId()}`,
|
|
email: data.login,
|
|
name: data.name || data.login,
|
|
emailVerified: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
image: data.avatar_url || undefined,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Box getUserInfo:', error)
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'dropbox',
|
|
clientId: env.DROPBOX_CLIENT_ID as string,
|
|
clientSecret: env.DROPBOX_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://www.dropbox.com/oauth2/authorize',
|
|
tokenUrl: 'https://api.dropboxapi.com/oauth2/token',
|
|
scopes: getCanonicalScopesForProvider('dropbox'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`,
|
|
pkce: true,
|
|
accessType: 'offline',
|
|
prompt: 'consent',
|
|
authorizationUrlParams: {
|
|
token_access_type: 'offline',
|
|
},
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch(
|
|
'https://api.dropboxapi.com/2/users/get_current_account',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
logger.error('Dropbox API error:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body: errorText,
|
|
})
|
|
throw new Error(`Dropbox API error: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
return {
|
|
id: `${data.account_id.toString()}-${generateId()}`,
|
|
email: data.email,
|
|
name: data.name?.display_name || data.email,
|
|
emailVerified: data.email_verified || false,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
image: data.profile_photo_url || undefined,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in getUserInfo:', error)
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'asana',
|
|
clientId: env.ASANA_CLIENT_ID as string,
|
|
clientSecret: env.ASANA_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://app.asana.com/-/oauth_authorize',
|
|
tokenUrl: 'https://app.asana.com/-/oauth_token',
|
|
userInfoUrl: 'https://app.asana.com/api/1.0/users/me',
|
|
scopes: getCanonicalScopesForProvider('asana'),
|
|
responseType: 'code',
|
|
pkce: false,
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/asana`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://app.asana.com/api/1.0/users/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Asana user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const result = await response.json()
|
|
const profile = result.data
|
|
|
|
const now = new Date()
|
|
|
|
return {
|
|
id: `${profile.gid.toString()}-${generateId()}`,
|
|
name: profile.name || 'Asana User',
|
|
email: profile.email || `${profile.gid}@asana.user`,
|
|
image: profile.photo?.image_128x128 || undefined,
|
|
emailVerified: !!profile.email,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Asana getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'slack',
|
|
clientId: env.SLACK_CLIENT_ID as string,
|
|
clientSecret: env.SLACK_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
|
tokenUrl: 'https://slack.com/api/oauth.v2.access',
|
|
userInfoUrl: 'https://slack.com/api/users.identity',
|
|
scopes: getCanonicalScopesForProvider('slack'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/slack`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
const response = await fetch('https://slack.com/api/auth.test', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Slack auth.test failed', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
if (!data.ok) {
|
|
logger.error('Slack auth.test returned error', { error: data.error })
|
|
return null
|
|
}
|
|
|
|
const teamId = data.team_id || 'unknown'
|
|
const teamName = data.team || 'Slack Workspace'
|
|
|
|
/**
|
|
* Tag the accountId with the installing user's Slack id (from the OAuth
|
|
* v2 `authed_user.id`, preserved on `tokens.raw`) behind a `usr_` marker.
|
|
* The channels selector uses it to scope private-channel visibility to
|
|
* the installer's own Slack membership, per Slack Marketplace rules. The
|
|
* marker disambiguates it from a legacy bot id (same `U.../B...` shape);
|
|
* absent it, we keep the legacy format and today's behavior.
|
|
*/
|
|
const rawTokens = (tokens as typeof tokens & { raw?: Record<string, unknown> }).raw
|
|
const authedUser = rawTokens?.authed_user as { id?: string } | undefined
|
|
const installerUserId = authedUser?.id
|
|
const userSegment = installerUserId
|
|
? `usr_${installerUserId}`
|
|
: data.user_id || data.bot_id || 'bot'
|
|
|
|
const uniqueId = `${teamId}-${userSegment}`
|
|
|
|
logger.info('Slack credential identifier', {
|
|
teamId,
|
|
userSegment,
|
|
uniqueId,
|
|
teamName,
|
|
hasInstallerId: !!installerUserId,
|
|
})
|
|
|
|
return {
|
|
id: `${uniqueId}-${generateId()}`,
|
|
name: teamName,
|
|
email: `${uniqueId}@slack.bot`,
|
|
emailVerified: false,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error creating Slack bot profile:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'webflow',
|
|
clientId: env.WEBFLOW_CLIENT_ID as string,
|
|
clientSecret: env.WEBFLOW_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://webflow.com/oauth/authorize',
|
|
tokenUrl: 'https://api.webflow.com/oauth/access_token',
|
|
userInfoUrl: 'https://api.webflow.com/v2/token/introspect',
|
|
scopes: getCanonicalScopesForProvider('webflow'),
|
|
responseType: 'code',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching Webflow user info')
|
|
|
|
const response = await fetch('https://api.webflow.com/v2/token/introspect', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Error fetching Webflow user info:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const data = await response.json()
|
|
const now = new Date()
|
|
|
|
const userId = data.user_id || 'user'
|
|
const uniqueId = `webflow-${userId}`
|
|
|
|
return {
|
|
id: `${uniqueId}-${generateId()}`,
|
|
name: data.user_name || 'Webflow User',
|
|
email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@webflow.user`,
|
|
emailVerified: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Webflow getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
{
|
|
providerId: 'linkedin',
|
|
clientId: env.LINKEDIN_CLIENT_ID as string,
|
|
clientSecret: env.LINKEDIN_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization',
|
|
tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
userInfoUrl: 'https://api.linkedin.com/v2/userinfo',
|
|
scopes: getCanonicalScopesForProvider('linkedin'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linkedin`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching LinkedIn user profile')
|
|
|
|
const response = await fetch('https://api.linkedin.com/v2/userinfo', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch LinkedIn user info', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const profile = await response.json()
|
|
|
|
return {
|
|
id: `${profile.sub}-${generateId()}`,
|
|
name: profile.name || 'LinkedIn User',
|
|
email: profile.email || `${profile.sub}@linkedin.user`,
|
|
emailVerified: profile.email_verified || true,
|
|
image: profile.picture || undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in LinkedIn getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'zoom',
|
|
clientId: env.ZOOM_CLIENT_ID as string,
|
|
clientSecret: env.ZOOM_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://zoom.us/oauth/authorize',
|
|
tokenUrl: 'https://zoom.us/oauth/token',
|
|
userInfoUrl: 'https://api.zoom.us/v2/users/me',
|
|
scopes: getCanonicalScopesForProvider('zoom'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
authentication: 'basic',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoom`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching Zoom user profile')
|
|
|
|
const response = await fetch('https://api.zoom.us/v2/users/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Zoom user info', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const profile = await response.json()
|
|
|
|
return {
|
|
id: `${profile.id.toString()}-${generateId()}`,
|
|
name:
|
|
`${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User',
|
|
email: profile.email || `${profile.id}@zoom.user`,
|
|
emailVerified: profile.verified === 1,
|
|
image: profile.pic_url || undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Zoom getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'spotify',
|
|
clientId: env.SPOTIFY_CLIENT_ID as string,
|
|
clientSecret: env.SPOTIFY_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://accounts.spotify.com/authorize',
|
|
tokenUrl: 'https://accounts.spotify.com/api/token',
|
|
userInfoUrl: 'https://api.spotify.com/v1/me',
|
|
scopes: getCanonicalScopesForProvider('spotify'),
|
|
responseType: 'code',
|
|
authentication: 'basic',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching Spotify user profile')
|
|
|
|
const response = await fetch('https://api.spotify.com/v1/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Spotify user info', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const profile = await response.json()
|
|
|
|
return {
|
|
id: `${profile.id.toString()}-${generateId()}`,
|
|
name: profile.display_name || 'Spotify User',
|
|
email: profile.email || `${profile.id}@spotify.user`,
|
|
emailVerified: true,
|
|
image: profile.images?.[0]?.url || undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Spotify getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
{
|
|
providerId: 'wordpress',
|
|
clientId: env.WORDPRESS_CLIENT_ID as string,
|
|
clientSecret: env.WORDPRESS_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://public-api.wordpress.com/oauth2/authorize',
|
|
tokenUrl: 'https://public-api.wordpress.com/oauth2/token',
|
|
userInfoUrl: 'https://public-api.wordpress.com/rest/v1.1/me',
|
|
scopes: getCanonicalScopesForProvider('wordpress'),
|
|
responseType: 'code',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching WordPress.com user profile')
|
|
|
|
const response = await fetch('https://public-api.wordpress.com/rest/v1.1/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch WordPress.com user info', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const profile = await response.json()
|
|
|
|
return {
|
|
id: `${profile.ID?.toString() || profile.id?.toString()}-${generateId()}`,
|
|
name: profile.display_name || profile.username || 'WordPress User',
|
|
email: profile.email || `${profile.username}@wordpress.com`,
|
|
emailVerified: profile.email_verified || false,
|
|
image: profile.avatar_URL || undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in WordPress.com getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
// DocuSign provider
|
|
{
|
|
providerId: 'docusign',
|
|
clientId: env.DOCUSIGN_CLIENT_ID as string,
|
|
clientSecret: env.DOCUSIGN_CLIENT_SECRET as string,
|
|
authorizationUrl: 'https://account-d.docusign.com/oauth/auth',
|
|
tokenUrl: 'https://account-d.docusign.com/oauth/token',
|
|
userInfoUrl: 'https://account-d.docusign.com/oauth/userinfo',
|
|
scopes: getCanonicalScopesForProvider('docusign'),
|
|
responseType: 'code',
|
|
accessType: 'offline',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/docusign`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching DocuSign user profile')
|
|
|
|
const response = await fetch('https://account-d.docusign.com/oauth/userinfo', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch DocuSign user info', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const data = await response.json()
|
|
const accounts = data.accounts ?? []
|
|
const defaultAccount =
|
|
accounts.find((a: { is_default: boolean }) => a.is_default) ?? accounts[0]
|
|
const accountName = defaultAccount?.account_name || 'DocuSign Account'
|
|
|
|
if (data.scope) {
|
|
tokens.scopes = data.scope.split(/\s+/).filter(Boolean)
|
|
}
|
|
|
|
return {
|
|
id: `${data.sub}-${generateId()}`,
|
|
name: data.name || accountName,
|
|
email: data.email || `${data.sub}@docusign.com`,
|
|
emailVerified: true,
|
|
image: undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in DocuSign getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
|
|
// Cal.com provider
|
|
{
|
|
providerId: 'calcom',
|
|
clientId: env.CALCOM_CLIENT_ID as string,
|
|
authorizationUrl: 'https://app.cal.com/auth/oauth2/authorize',
|
|
tokenUrl: 'https://app.cal.com/api/auth/oauth/token',
|
|
scopes: getCanonicalScopesForProvider('calcom'),
|
|
responseType: 'code',
|
|
pkce: true,
|
|
accessType: 'offline',
|
|
prompt: 'consent',
|
|
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/calcom`,
|
|
getUserInfo: async (tokens) => {
|
|
try {
|
|
logger.info('Fetching Cal.com user profile')
|
|
|
|
const response = await fetch('https://api.cal.com/v2/me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.accessToken}`,
|
|
'cal-api-version': '2024-08-13',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
logger.error('Failed to fetch Cal.com user info', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
throw new Error('Failed to fetch user info')
|
|
}
|
|
|
|
const data = await response.json()
|
|
const profile = data.data || data
|
|
|
|
return {
|
|
id: `${profile.id?.toString()}-${generateId()}`,
|
|
name: profile.name || 'Cal.com User',
|
|
email: profile.email || `${profile.id}@cal.com`,
|
|
emailVerified: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error in Cal.com getUserInfo:', { error })
|
|
return null
|
|
}
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
// Include SSO plugin when enabled
|
|
...(env.SSO_ENABLED
|
|
? [
|
|
sso({
|
|
/**
|
|
* Honor the IdP's verified-email claim. Without this the SSO plugin
|
|
* forces `emailVerified: false`, blocking automatic linking of an SSO
|
|
* login to an existing same-email account (Better Auth "account not linked").
|
|
*/
|
|
trustEmailVerified: 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 }) => {
|
|
return await authorizeSubscriptionReference(user.id, referenceId, action)
|
|
},
|
|
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 }
|
|
)
|
|
}
|
|
|
|
await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe)
|
|
|
|
const subscriptionForOrg = {
|
|
...subscription,
|
|
plan: planFromStripe ?? subscription.plan,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan
|
|
|
|
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 }
|
|
)
|
|
}
|
|
|
|
await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe)
|
|
|
|
const subscriptionForOrg = {
|
|
...subscription,
|
|
plan: planFromStripe ?? subscription.plan,
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
} else {
|
|
await handleOrganizationPlanDowngrade(
|
|
{
|
|
id: resolvedSubscription.id,
|
|
plan: effectivePlanForTeamFeatures ?? null,
|
|
referenceId: resolvedSubscription.referenceId,
|
|
status: resolvedSubscription.status ?? null,
|
|
},
|
|
event.id
|
|
)
|
|
}
|
|
|
|
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': {
|
|
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: isEmailVerificationEnabled,
|
|
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)
|