chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AccessControlConfig } from '@/lib/auth/access-control'
const { mockFetch, envRef, flagRef } = vi.hoisted(() => ({
mockFetch: vi.fn(),
envRef: {
APPCONFIG_APPLICATION: 'sim-staging' as string | undefined,
APPCONFIG_ENVIRONMENT: 'staging' as string | undefined,
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
BLOCKED_EMAILS: undefined as string | undefined,
ALLOWED_LOGIN_EMAILS: undefined as string | undefined,
ALLOWED_LOGIN_DOMAINS: undefined as string | undefined,
BLOCKED_EMAIL_MX_HOSTS: undefined as string | undefined,
},
flagRef: { isAppConfigEnabled: false },
}))
vi.mock('@/lib/core/config/appconfig', () => ({
fetchAppConfigProfile: mockFetch,
}))
vi.mock('@/lib/core/config/env', () => ({
get env() {
return envRef
},
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isAppConfigEnabled() {
return flagRef.isAppConfigEnabled
},
}))
import {
getAccessControlConfig,
isEmailBlockedByAccessControl,
isEmailInDenylist,
} from '@/lib/auth/access-control'
const empty: AccessControlConfig = {
blockedSignupDomains: [],
blockedEmails: [],
allowedLoginEmails: [],
allowedLoginDomains: [],
blockedEmailMxHosts: [],
}
describe('getAccessControlConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
flagRef.isAppConfigEnabled = false
Object.assign(envRef, {
BLOCKED_SIGNUP_DOMAINS: undefined,
BLOCKED_EMAILS: undefined,
ALLOWED_LOGIN_EMAILS: undefined,
ALLOWED_LOGIN_DOMAINS: undefined,
BLOCKED_EMAIL_MX_HOSTS: undefined,
})
})
describe('env fallback (AppConfig disabled)', () => {
it('returns empty lists when nothing is set', async () => {
expect(await getAccessControlConfig()).toEqual(empty)
expect(mockFetch).not.toHaveBeenCalled()
})
it('parses, trims, lowercases, and dedupes csv env vars', async () => {
envRef.BLOCKED_SIGNUP_DOMAINS = 'Gmail.com, yahoo.com ,gmail.com,'
envRef.BLOCKED_EMAILS = 'Spam@Evil.com, spam@evil.com'
envRef.ALLOWED_LOGIN_DOMAINS = 'Sim.ai'
const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['gmail.com', 'yahoo.com'])
expect(result.blockedEmails).toEqual(['spam@evil.com'])
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
expect(mockFetch).not.toHaveBeenCalled()
})
})
describe('AppConfig source (enabled)', () => {
beforeEach(() => {
flagRef.isAppConfigEnabled = true
})
it('reads the access-control profile and normalizes the payload', async () => {
mockFetch.mockImplementation((_ids, parse) =>
Promise.resolve(
parse({
blockedSignupDomains: ['X.com'],
allowedLoginDomains: ['sim.ai'],
blockedEmailMxHosts: 'not-an-array',
})
)
)
const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['x.com'])
expect(result.allowedLoginDomains).toEqual(['sim.ai'])
expect(result.blockedEmailMxHosts).toEqual([])
expect(mockFetch).toHaveBeenCalledWith(
{ application: 'sim-staging', environment: 'staging', profile: 'access-control' },
expect.any(Function)
)
})
it('falls back to env vars when the fetch yields null', async () => {
envRef.BLOCKED_SIGNUP_DOMAINS = 'spam.example'
mockFetch.mockResolvedValue(null)
const result = await getAccessControlConfig()
expect(result.blockedSignupDomains).toEqual(['spam.example'])
})
})
})
describe('isEmailInDenylist', () => {
it('returns false when denylist is null, empty, or email is missing', () => {
expect(isEmailInDenylist('a@example.com', null)).toBe(false)
expect(isEmailInDenylist('a@example.com', [])).toBe(false)
expect(isEmailInDenylist(null, ['example.com'])).toBe(false)
expect(isEmailInDenylist(undefined, ['example.com'])).toBe(false)
expect(isEmailInDenylist('', ['example.com'])).toBe(false)
})
it('returns false when email has no @', () => {
expect(isEmailInDenylist('not-an-email', ['example.com'])).toBe(false)
})
it('matches exact domain', () => {
expect(isEmailInDenylist('user@dpdns.org', ['dpdns.org'])).toBe(true)
expect(isEmailInDenylist('user@DPDNS.ORG', ['dpdns.org'])).toBe(true)
})
it('matches arbitrary-depth subdomains of a listed parent zone', () => {
expect(isEmailInDenylist('user@xx.lucky04.dpdns.org', ['dpdns.org'])).toBe(true)
expect(isEmailInDenylist('user@a.b.c.qzz.io', ['qzz.io'])).toBe(true)
})
it('does not match look-alike domains', () => {
expect(isEmailInDenylist('user@xdpdns.org', ['dpdns.org'])).toBe(false)
expect(isEmailInDenylist('user@notdpdns.org', ['dpdns.org'])).toBe(false)
})
it('does not match disallowed domains', () => {
expect(isEmailInDenylist('user@gmail.com', ['dpdns.org', 'qzz.io'])).toBe(false)
expect(isEmailInDenylist('user@example.com', ['dpdns.org'])).toBe(false)
})
it('handles multiple denylist entries', () => {
const denylist = ['dpdns.org', 'qzz.io', 'cc.cd']
expect(isEmailInDenylist('user@foo.dpdns.org', denylist)).toBe(true)
expect(isEmailInDenylist('user@bar.qzz.io', denylist)).toBe(true)
expect(isEmailInDenylist('user@baz.cc.cd', denylist)).toBe(true)
expect(isEmailInDenylist('user@example.com', denylist)).toBe(false)
})
})
describe('isEmailBlockedByAccessControl', () => {
const config: AccessControlConfig = {
...empty,
blockedSignupDomains: ['bad.com'],
blockedEmails: ['spam@evil.com'],
}
it('matches individually blocked emails case-insensitively', () => {
expect(isEmailBlockedByAccessControl('spam@evil.com', config)).toBe(true)
expect(isEmailBlockedByAccessControl(' Spam@Evil.com ', config)).toBe(true)
expect(isEmailBlockedByAccessControl('other@evil.com', config)).toBe(false)
})
it('matches blocked domains and subdomains', () => {
expect(isEmailBlockedByAccessControl('a@bad.com', config)).toBe(true)
expect(isEmailBlockedByAccessControl('a@mail.bad.com', config)).toBe(true)
expect(isEmailBlockedByAccessControl('a@good.com', config)).toBe(false)
})
it('returns false for missing emails and empty config', () => {
expect(isEmailBlockedByAccessControl(null, config)).toBe(false)
expect(isEmailBlockedByAccessControl(undefined, config)).toBe(false)
expect(isEmailBlockedByAccessControl('a@bad.com', empty)).toBe(false)
})
})
+107
View File
@@ -0,0 +1,107 @@
import { normalizeEmail } from '@sim/utils/string'
import { fetchAppConfigProfile } from '@/lib/core/config/appconfig'
import { env } from '@/lib/core/config/env'
import { isAppConfigEnabled } from '@/lib/core/config/env-flags'
/**
* Name of the AppConfig configuration profile holding the signup/login gating
* lists. This is a cross-repo contract: it must match the `CfnConfigurationProfile`
* name created by the infra stack.
*/
const ACCESS_CONTROL_PROFILE = 'access-control'
/**
* Normalized signup/login gating lists. All entries are trimmed, lowercased, and
* de-duplicated. Domains are bare hostnames; MX hosts are substrings matched
* against resolved MX exchanges; emails are full addresses.
*/
export interface AccessControlConfig {
blockedSignupDomains: string[]
blockedEmails: string[]
allowedLoginEmails: string[]
allowedLoginDomains: string[]
blockedEmailMxHosts: string[]
}
/**
* True when the email's domain matches a denylist entry exactly or is a
* subdomain of one.
*/
export function isEmailInDenylist(
email: string | undefined | null,
denylist: readonly string[] | null
): boolean {
if (!denylist || denylist.length === 0 || !email) return false
const domain = email.split('@')[1]?.toLowerCase()
if (!domain) return false
return denylist.some((entry) => domain === entry || domain.endsWith(`.${entry}`))
}
/**
* True when the email is individually banned (`blockedEmails`) or its domain
* is in the blocked-domains list. The single predicate for "this email must
* not sign up, sign in, or execute anything".
*/
export function isEmailBlockedByAccessControl(
email: string | undefined | null,
config: AccessControlConfig
): boolean {
if (!email) return false
const normalized = normalizeEmail(email)
if (config.blockedEmails.includes(normalized)) return true
return isEmailInDenylist(normalized, config.blockedSignupDomains)
}
function normalizeList(values: unknown): string[] {
if (!Array.isArray(values)) return []
return Array.from(new Set(values.map((v) => String(v).trim().toLowerCase()).filter(Boolean)))
}
function parseCsv(value: string | undefined): string[] {
return normalizeList(value?.split(','))
}
/**
* Fallback source for self-hosted/OSS/local deployments that have no AppConfig.
* Reads the same env vars the app used before AppConfig.
*/
function fromEnv(): AccessControlConfig {
return {
blockedSignupDomains: parseCsv(env.BLOCKED_SIGNUP_DOMAINS),
blockedEmails: parseCsv(env.BLOCKED_EMAILS),
allowedLoginEmails: parseCsv(env.ALLOWED_LOGIN_EMAILS),
allowedLoginDomains: parseCsv(env.ALLOWED_LOGIN_DOMAINS),
blockedEmailMxHosts: parseCsv(env.BLOCKED_EMAIL_MX_HOSTS),
}
}
function parseConfig(json: unknown): AccessControlConfig {
const obj = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
return {
blockedSignupDomains: normalizeList(obj.blockedSignupDomains),
blockedEmails: normalizeList(obj.blockedEmails),
allowedLoginEmails: normalizeList(obj.allowedLoginEmails),
allowedLoginDomains: normalizeList(obj.allowedLoginDomains),
blockedEmailMxHosts: normalizeList(obj.blockedEmailMxHosts),
}
}
/**
* Resolve the current signup/login gating lists. Reads from AWS AppConfig on
* hosted deployments (cached, ~30s TTL, never blocks after the first fetch),
* otherwise falls back to env vars so self-hosted/OSS works with no AWS.
*/
export async function getAccessControlConfig(): Promise<AccessControlConfig> {
if (!isAppConfigEnabled) return fromEnv()
const value = await fetchAppConfigProfile(
{
application: env.APPCONFIG_APPLICATION as string,
environment: env.APPCONFIG_ENVIRONMENT as string,
profile: ACCESS_CONTROL_PROFILE,
},
parseConfig
)
return value ?? fromEnv()
}
+11
View File
@@ -0,0 +1,11 @@
import { headers } from 'next/headers'
import { auth } from './auth'
export async function setActiveOrganizationForCurrentSession(
organizationId: string | null
): Promise<void> {
await auth.api.setActiveOrganization({
body: { organizationId },
headers: await headers(),
})
}
+105
View File
@@ -0,0 +1,105 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'
const logger = createLogger('AnonymousAuth')
let anonymousUserEnsured = false
/**
* Ensures the anonymous user and their stats record exist in the database.
* Called when DISABLE_AUTH is enabled to ensure DB operations work.
*/
export async function ensureAnonymousUserExists(): Promise<void> {
if (anonymousUserEnsured) return
try {
const existingUser = await db.query.user.findFirst({
where: eq(schema.user.id, ANONYMOUS_USER_ID),
})
if (!existingUser) {
const now = new Date()
await db.insert(schema.user).values({
...ANONYMOUS_USER,
createdAt: now,
updatedAt: now,
})
logger.info('Created anonymous user for DISABLE_AUTH mode')
}
const existingStats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, ANONYMOUS_USER_ID),
})
if (!existingStats) {
await db.insert(schema.userStats).values({
id: generateId(),
userId: ANONYMOUS_USER_ID,
currentUsageLimit: '10000000000',
})
logger.info('Created anonymous user stats for DISABLE_AUTH mode')
}
anonymousUserEnsured = true
} catch (error) {
if (
error instanceof Error &&
(error.message.includes('unique') || error.message.includes('duplicate'))
) {
anonymousUserEnsured = true
return
}
logger.error('Failed to ensure anonymous user exists', { error })
throw error
}
}
export interface AnonymousSession {
user: {
id: string
name: string
email: string
emailVerified: boolean
image: null
createdAt: Date
updatedAt: Date
}
session: {
id: string
userId: string
expiresAt: Date
createdAt: Date
updatedAt: Date
token: string
ipAddress: null
userAgent: null
}
}
/**
* Creates an anonymous session for when auth is disabled.
*/
export function createAnonymousSession(): AnonymousSession {
const now = new Date()
return {
user: {
...ANONYMOUS_USER,
createdAt: now,
updatedAt: now,
},
session: {
id: 'anonymous-session',
userId: ANONYMOUS_USER_ID,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
createdAt: now,
updatedAt: now,
token: 'anonymous-token',
ipAddress: null,
userAgent: null,
},
}
}
+65
View File
@@ -0,0 +1,65 @@
import { useContext } from 'react'
import { ssoClient } from '@better-auth/sso/client'
import { stripeClient } from '@better-auth/stripe/client'
import {
adminClient,
customSessionClient,
emailOTPClient,
genericOAuthClient,
organizationClient,
} from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import type { auth } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls'
import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider'
function getAuthBaseUrl(): string {
return getBrowserOrigin() ?? getBaseUrl()
}
export const client = createAuthClient({
baseURL: getAuthBaseUrl(),
plugins: [
adminClient(),
emailOTPClient(),
genericOAuthClient(),
customSessionClient<typeof auth>(),
...(isBillingEnabled
? [
stripeClient({
subscription: true, // Enable subscription management
}),
]
: []),
...(isOrganizationsEnabled ? [organizationClient()] : []),
...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []),
],
})
export function useSession(): SessionHookResult {
const ctx = useContext(SessionContext)
if (!ctx) {
throw new Error(
'SessionProvider is not mounted. Wrap your app with <SessionProvider> in app/layout.tsx.'
)
}
return ctx
}
export const useActiveOrganization = isOrganizationsEnabled
? client.useActiveOrganization
: () => ({ data: undefined, isPending: false, error: null })
export const useSubscription = () => {
return {
list: client.subscription?.list,
upgrade: client.subscription?.upgrade,
cancel: client.subscription?.cancel,
restore: client.subscription?.restore,
}
}
const { signIn, signUp, signOut } = client
export { signOut }
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockWhere, envRef } = vi.hoisted(() => ({
mockWhere: vi.fn(),
envRef: {
BLOCKED_SIGNUP_DOMAINS: undefined as string | undefined,
BLOCKED_EMAILS: undefined as string | undefined,
},
}))
vi.mock('@sim/db', () => ({
db: { select: vi.fn(() => ({ from: vi.fn(() => ({ where: mockWhere })) })) },
user: { id: 'id', email: 'email', banned: 'banned', banExpires: 'banExpires' },
}))
vi.mock('drizzle-orm', () => ({ inArray: vi.fn(), sql: vi.fn() }))
vi.mock('@/lib/core/config/appconfig', () => ({ fetchAppConfigProfile: vi.fn() }))
vi.mock('@/lib/core/config/env', () => ({
get env() {
return envRef
},
}))
vi.mock('@/lib/core/config/env-flags', () => ({ isAppConfigEnabled: false }))
import { getActivelyBannedUserIds, isBanActive, isEmailBlocked } from '@/lib/auth/ban'
describe('isBanActive', () => {
it('returns true for a permanent ban', () => {
expect(isBanActive({ banned: true, banExpires: null })).toBe(true)
})
it('returns false for an expired temporary ban', () => {
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() - 1000) })).toBe(false)
})
it('returns true for an unexpired temporary ban', () => {
expect(isBanActive({ banned: true, banExpires: new Date(Date.now() + 60_000) })).toBe(true)
})
it('returns false when not banned', () => {
expect(isBanActive({ banned: false, banExpires: null })).toBe(false)
expect(isBanActive({ banned: null, banExpires: null })).toBe(false)
})
})
describe('isEmailBlocked', () => {
beforeEach(() => {
vi.clearAllMocks()
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
envRef.BLOCKED_EMAILS = 'spam@evil.com'
mockWhere.mockResolvedValue([])
})
it('returns true for blocked domains and subdomains without querying users', async () => {
expect(await isEmailBlocked('a@bad.com')).toBe(true)
expect(await isEmailBlocked('a@mail.bad.com')).toBe(true)
expect(mockWhere).not.toHaveBeenCalled()
})
it('returns true for individually blocked emails without querying users', async () => {
expect(await isEmailBlocked('spam@evil.com')).toBe(true)
expect(mockWhere).not.toHaveBeenCalled()
})
it('returns true when the email belongs to an actively banned account', async () => {
mockWhere.mockResolvedValue([{ banned: true, banExpires: null }])
expect(await isEmailBlocked('a@good.com')).toBe(true)
})
it('returns false for clean accounts and missing emails', async () => {
expect(await isEmailBlocked('a@good.com')).toBe(false)
expect(await isEmailBlocked(null)).toBe(false)
expect(await isEmailBlocked(undefined)).toBe(false)
})
})
describe('getActivelyBannedUserIds', () => {
beforeEach(() => {
vi.clearAllMocks()
envRef.BLOCKED_SIGNUP_DOMAINS = undefined
envRef.BLOCKED_EMAILS = undefined
mockWhere.mockResolvedValue([])
})
it('short-circuits on empty input without querying', async () => {
expect(await getActivelyBannedUserIds([])).toEqual([])
expect(await getActivelyBannedUserIds([''])).toEqual([])
expect(mockWhere).not.toHaveBeenCalled()
})
it('returns ids with an active db ban', async () => {
mockWhere.mockResolvedValue([
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: null },
{ id: 'u2', email: 'b@ok.com', banned: false, banExpires: null },
])
expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1'])
})
it('treats an expired ban as lifted', async () => {
mockWhere.mockResolvedValue([
{ id: 'u1', email: 'a@ok.com', banned: true, banExpires: new Date(Date.now() - 1000) },
])
expect(await getActivelyBannedUserIds(['u1'])).toEqual([])
})
it('returns ids whose email is individually blocked', async () => {
envRef.BLOCKED_EMAILS = 'spam@evil.com'
mockWhere.mockResolvedValue([
{ id: 'u1', email: 'spam@evil.com', banned: false, banExpires: null },
{ id: 'u2', email: 'ok@evil.com', banned: false, banExpires: null },
])
expect(await getActivelyBannedUserIds(['u1', 'u2'])).toEqual(['u1'])
})
it('returns ids whose email domain is in the blocked-domains list, including subdomains', async () => {
envRef.BLOCKED_SIGNUP_DOMAINS = 'bad.com'
mockWhere.mockResolvedValue([
{ id: 'u1', email: 'a@bad.com', banned: false, banExpires: null },
{ id: 'u2', email: 'b@mail.bad.com', banned: false, banExpires: null },
{ id: 'u3', email: 'c@good.com', banned: false, banExpires: null },
])
expect(await getActivelyBannedUserIds(['u1', 'u2', 'u3'])).toEqual(['u1', 'u2'])
})
it('propagates db failures so callers fail closed', async () => {
mockWhere.mockRejectedValue(new Error('db down'))
await expect(getActivelyBannedUserIds(['u1'])).rejects.toThrow('db down')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { db, user } from '@sim/db'
import { inArray, sql } from 'drizzle-orm'
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
/**
* True when a ban is currently in effect. Mirrors better-auth admin-plugin
* semantics: a ban whose `banExpires` is in the past is treated as lifted.
*/
export function isBanActive(row: { banned: boolean | null; banExpires: Date | null }): boolean {
if (!row.banned) return false
if (row.banExpires && row.banExpires.getTime() <= Date.now()) return false
return true
}
/**
* True when a raw email (e.g. an inbound sender) is blocked: it is in the
* appconfig blocked-emails list, its domain is in the blocked-domains list,
* or it belongs to an account with an active ban. Covers senders that don't
* resolve to a known user id.
*/
export async function isEmailBlocked(email: string | null | undefined): Promise<boolean> {
if (!email) return false
const accessControl = await getAccessControlConfig()
if (isEmailBlockedByAccessControl(email, accessControl)) return true
const rows = await db
.select({ banned: user.banned, banExpires: user.banExpires })
.from(user)
.where(sql`lower(${user.email}) = ${email.toLowerCase()}`)
return rows.some(isBanActive)
}
/**
* Returns the subset of the given user ids that are currently blocked: an
* active account ban, or an email/domain in the appconfig blocked lists.
* One user query plus the cached access-control fetch. Throws on db
* failure — callers must fail closed.
*/
export async function getActivelyBannedUserIds(userIds: string[]): Promise<string[]> {
const ids = [...new Set(userIds.filter(Boolean))]
if (ids.length === 0) return []
const [accessControl, rows] = await Promise.all([
getAccessControlConfig(),
db
.select({ id: user.id, email: user.email, banned: user.banned, banExpires: user.banExpires })
.from(user)
.where(inArray(user.id, ids)),
])
return rows
.filter((row) => isBanActive(row) || isEmailBlockedByAccessControl(row.email, accessControl))
.map((row) => row.id)
}
+86
View File
@@ -0,0 +1,86 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
getRequestedSignInProviderId,
isSignInProviderAllowed,
SIGN_IN_PROVIDER_IDS,
} from '@/lib/auth/constants'
describe('sign-in provider allowlist', () => {
it('permits only the first-party login providers', () => {
expect([...SIGN_IN_PROVIDER_IDS]).toEqual(['google', 'github', 'microsoft'])
})
it('allows first-party login providers to sign in', () => {
for (const providerId of SIGN_IN_PROVIDER_IDS) {
expect(isSignInProviderAllowed(providerId)).toBe(true)
}
})
it('rejects integration connectors from the sign-in endpoints', () => {
const connectors = [
'microsoft-ad',
'microsoft-teams',
'microsoft-excel',
'outlook',
'onedrive',
'sharepoint',
'salesforce',
'jira',
'confluence',
'hubspot',
'box',
'wordpress',
'google-drive',
'google-sheets',
'vertex-ai',
]
for (const providerId of connectors) {
expect(isSignInProviderAllowed(providerId)).toBe(false)
}
})
it('rejects missing or malformed provider identifiers', () => {
expect(isSignInProviderAllowed(undefined)).toBe(false)
expect(isSignInProviderAllowed(null)).toBe(false)
expect(isSignInProviderAllowed('')).toBe(false)
expect(isSignInProviderAllowed(123)).toBe(false)
expect(isSignInProviderAllowed({ provider: 'google' })).toBe(false)
})
})
describe('getRequestedSignInProviderId', () => {
it('reads the `provider` field on /sign-in/social', () => {
expect(getRequestedSignInProviderId('/sign-in/social', { provider: 'microsoft' })).toBe(
'microsoft'
)
})
it('reads the `providerId` field on /sign-in/oauth2', () => {
expect(getRequestedSignInProviderId('/sign-in/oauth2', { providerId: 'salesforce' })).toBe(
'salesforce'
)
})
it('checks the field the /sign-in/oauth2 handler uses, ignoring a decoy `provider`', () => {
const body = { provider: 'google', providerId: 'salesforce' }
const resolved = getRequestedSignInProviderId('/sign-in/oauth2', body)
expect(resolved).toBe('salesforce')
expect(isSignInProviderAllowed(resolved)).toBe(false)
})
it('checks the field the /sign-in/social handler uses, ignoring a decoy `providerId`', () => {
const body = { provider: 'salesforce', providerId: 'google' }
const resolved = getRequestedSignInProviderId('/sign-in/social', body)
expect(resolved).toBe('salesforce')
expect(isSignInProviderAllowed(resolved)).toBe(false)
})
it('returns undefined for unrelated paths and missing bodies', () => {
expect(getRequestedSignInProviderId('/sign-in/email', { provider: 'google' })).toBeUndefined()
expect(getRequestedSignInProviderId('/sign-in/social', undefined)).toBeUndefined()
expect(getRequestedSignInProviderId('/sign-in/oauth2', null)).toBeUndefined()
})
})
+54
View File
@@ -0,0 +1,54 @@
/** Anonymous user ID used when DISABLE_AUTH is enabled */
export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000'
export const ANONYMOUS_USER = {
id: ANONYMOUS_USER_ID,
name: 'Anonymous',
email: 'anonymous@localhost',
emailVerified: true,
image: null,
} as const
/**
* Provider IDs permitted to authenticate (create or link a session) through the
* unauthenticated sign-in endpoints `/sign-in/social` and `/sign-in/oauth2`.
*
* Only first-party login providers that verify email ownership belong here.
* Integration connectors (Salesforce, Jira, the Microsoft/Google work
* connectors, etc.) are deliberately excluded: they are connected exclusively
* through the authenticated `/oauth2/link` flow, which binds the new account to
* the current session user and never mints a session. Allowing a connector to
* reach the sign-in endpoints enables nOAuth-style account takeover, where a
* multi-tenant IdP asserting an attacker-controlled, unverified email mints a
* session for the matching existing user. SSO uses a separate `/sign-in/sso`
* endpoint and is unaffected by this list.
*/
export const SIGN_IN_PROVIDER_IDS = ['google', 'github', 'microsoft'] as const
const signInProviderIdSet: ReadonlySet<string> = new Set(SIGN_IN_PROVIDER_IDS)
/**
* Returns true when `providerId` is a first-party login provider allowed to sign
* in. Used to reject integration connectors at the sign-in endpoints so they can
* only ever be connected through the authenticated link flow.
*/
export function isSignInProviderAllowed(providerId: unknown): boolean {
return typeof providerId === 'string' && signInProviderIdSet.has(providerId)
}
/**
* Resolves the provider identifier the sign-in handler will actually act on for a
* given auth path. Better Auth reads `provider` on `/sign-in/social` and
* `providerId` on `/sign-in/oauth2`, so the allowlist guard must read the same
* field the handler uses. Reading the wrong field would let a request pass the
* guard with an allowed value in one field while the handler starts OAuth for a
* blocked value in the other, reopening connector sign-in.
*/
export function getRequestedSignInProviderId(
path: string,
body: { provider?: unknown; providerId?: unknown } | null | undefined
): unknown {
if (path === '/sign-in/social') return body?.provider
if (path === '/sign-in/oauth2') return body?.providerId
return undefined
}
+295
View File
@@ -0,0 +1,295 @@
import { db } from '@sim/db'
import { account, credential, credentialMember, workflow as workflowTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
export interface CredentialAccessResult {
ok: boolean
error?: string
authType?: typeof AuthType.SESSION | typeof AuthType.INTERNAL_JWT
requesterUserId?: string
credentialOwnerUserId?: string
workspaceId?: string
resolvedCredentialId?: string
credentialType?: 'oauth' | 'service_account'
}
/**
* Centralizes auth + credential membership checks for OAuth usage.
* - Workspace-scoped credential IDs enforce active credential_member access.
* - Legacy account IDs are resolved to workspace-scoped credentials when workflowId is provided.
* - Direct legacy account-ID access without workflowId is restricted to account owners only.
*/
export async function authorizeCredentialUse(
request: NextRequest,
params: {
credentialId: string
workflowId?: string
requireWorkflowIdForInternal?: boolean
callerUserId?: string
}
): Promise<CredentialAccessResult> {
const { credentialId, workflowId, requireWorkflowIdForInternal = true, callerUserId } = params
const auth = await checkSessionOrInternalAuth(request, {
requireWorkflowId: requireWorkflowIdForInternal,
})
if (!auth.success || !auth.userId) {
return { ok: false, error: auth.error || 'Authentication required' }
}
if (
auth.authType === AuthType.INTERNAL_JWT &&
callerUserId !== undefined &&
callerUserId !== auth.userId
) {
return { ok: false, error: 'Caller user does not match internal token subject' }
}
const actingUserId = auth.userId
const [workflowContext] = workflowId
? await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
: [null]
if (workflowId && (!workflowContext || !workflowContext.workspaceId)) {
return { ok: false, error: 'Workflow not found' }
}
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
accountId: credential.accountId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
platformCredential.workspaceId
)
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (requesterPerm === null) {
return { ok: false, error: 'You do not have access to this workspace.' }
}
if (!membership && requesterPerm !== 'admin') {
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: actingUserId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.id,
credentialType: 'service_account',
}
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return { ok: false, error: 'Unsupported credential type for OAuth access' }
}
if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) {
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
}
const [accountRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, platformCredential.accountId))
.limit(1)
if (!accountRow) {
return { ok: false, error: 'Credential account not found' }
}
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
platformCredential.workspaceId
)
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (requesterPerm === null) {
return {
ok: false,
error: 'You do not have access to this workspace.',
}
}
if (!membership && requesterPerm !== 'admin') {
return {
ok: false,
error: `You do not have access to this credential. Ask the credential admin to add you as a member.`,
}
}
const ownerPerm = await getUserEntityPermissions(
accountRow.userId,
'workspace',
platformCredential.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: accountRow.userId,
workspaceId: platformCredential.workspaceId,
resolvedCredentialId: platformCredential.accountId,
credentialType: 'oauth',
}
}
if (workflowContext?.workspaceId) {
const [workspaceCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
accountId: credential.accountId,
})
.from(credential)
.where(
and(
eq(credential.type, 'oauth'),
eq(credential.workspaceId, workflowContext.workspaceId),
eq(credential.accountId, credentialId)
)
)
.limit(1)
if (!workspaceCredential?.accountId) {
return { ok: false, error: 'Credential not found' }
}
const [accountRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, workspaceCredential.accountId))
.limit(1)
if (!accountRow) {
return { ok: false, error: 'Credential account not found' }
}
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, workspaceCredential.id),
eq(credentialMember.userId, actingUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
const requesterPerm = await getUserEntityPermissions(
actingUserId,
'workspace',
workflowContext.workspaceId
)
if (requesterPerm !== 'admin') {
return {
ok: false,
error:
'You do not have access to this credential. Ask the credential admin to add you as a member.',
}
}
}
const ownerPerm = await getUserEntityPermissions(
accountRow.userId,
'workspace',
workflowContext.workspaceId
)
if (ownerPerm === null) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: accountRow.userId,
workspaceId: workflowContext.workspaceId,
resolvedCredentialId: workspaceCredential.accountId,
credentialType: 'oauth',
}
}
const [legacyAccount] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, credentialId))
.limit(1)
if (!legacyAccount) {
return { ok: false, error: 'Credential not found' }
}
if (auth.authType === AuthType.INTERNAL_JWT) {
return { ok: false, error: 'workflowId is required' }
}
if (auth.userId !== legacyAccount.userId) {
return { ok: false, error: 'Unauthorized' }
}
return {
ok: true,
authType: auth.authType as CredentialAccessResult['authType'],
requesterUserId: auth.userId,
credentialOwnerUserId: legacyAccount.userId,
resolvedCredentialId: credentialId,
credentialType: 'oauth',
}
}
+237
View File
@@ -0,0 +1,237 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { getSession } from '@/lib/auth'
import { verifyInternalToken } from '@/lib/auth/internal'
const logger = createLogger('HybridAuth')
export const AuthType = {
SESSION: 'session',
API_KEY: 'api_key',
INTERNAL_JWT: 'internal_jwt',
} as const
export type AuthTypeValue = (typeof AuthType)[keyof typeof AuthType]
const API_KEY_HEADER = 'x-api-key'
const BEARER_PREFIX = 'Bearer '
/**
* Lightweight header-only check for whether a request carries external API credentials.
* Does NOT validate the credentials — only inspects headers to classify the request
* as programmatic API traffic vs interactive session traffic.
*/
export function hasExternalApiCredentials(headers: Headers): boolean {
if (headers.has(API_KEY_HEADER)) return true
const auth = headers.get('authorization')
return auth?.startsWith(BEARER_PREFIX) ?? false
}
export interface AuthResult {
success: boolean
userId?: string
workspaceId?: string
userName?: string | null
userEmail?: string | null
authType?: AuthTypeValue
apiKeyType?: 'personal' | 'workspace'
error?: string
}
/**
* Resolves userId from a verified internal JWT token.
* Only trusts the userId embedded in the JWT payload — never from user-controlled sources.
*/
function resolveUserFromJwt(
verificationUserId: string | null,
options: { requireWorkflowId?: boolean }
): AuthResult {
if (verificationUserId) {
return { success: true, userId: verificationUserId, authType: AuthType.INTERNAL_JWT }
}
if (options.requireWorkflowId !== false) {
return { success: false, error: 'userId required but not present in JWT' }
}
return { success: true, authType: AuthType.INTERNAL_JWT }
}
/**
* Check for internal JWT authentication only.
* Use this for routes that should ONLY be accessible by the executor (server-to-server).
* Rejects session and API key authentication.
*
* @param request - The incoming request
* @param options - Optional configuration
* @param options.requireWorkflowId - Whether workflowId/userId is required (default: true)
*/
export async function checkInternalAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
try {
const authHeader = request.headers.get('authorization')
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
return {
success: false,
error: 'API key access not allowed for this endpoint. Use workflow execution instead.',
}
}
if (!authHeader?.startsWith('Bearer ')) {
return {
success: false,
error: 'Internal authentication required',
}
}
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
if (!verification.valid) {
return { success: false, error: 'Invalid internal token' }
}
return resolveUserFromJwt(verification.userId || null, options)
} catch (error) {
logger.error('Error in internal authentication:', error)
return {
success: false,
error: 'Authentication error',
}
}
}
/**
* Check for session or internal JWT authentication.
* Use this for routes that should be accessible by the UI and executor,
* but NOT by external API keys.
*
* @param request - The incoming request
* @param options - Optional configuration
* @param options.requireWorkflowId - Whether workflowId/userId is required for JWT (default: true)
*/
export async function checkSessionOrInternalAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
try {
// 1. Reject API keys first
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
return {
success: false,
error: 'API key access not allowed for this endpoint',
}
}
// 2. Check for internal JWT token
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
if (verification.valid) {
return resolveUserFromJwt(verification.userId || null, options)
}
}
// 3. Try session auth (for web UI)
const session = await getSession()
if (session?.user?.id) {
return {
success: true,
userId: session.user.id,
userName: session.user.name,
userEmail: session.user.email,
authType: AuthType.SESSION,
}
}
return {
success: false,
error: 'Unauthorized',
}
} catch (error) {
logger.error('Error in session/internal authentication:', error)
return {
success: false,
error: 'Authentication error',
}
}
}
/**
* Check for authentication using any of the 3 supported methods:
* 1. Session authentication (cookies)
* 2. API key authentication (X-API-Key header)
* 3. Internal JWT authentication (Authorization: Bearer header)
*
* For internal JWT calls, requires workflowId to determine user context
*/
export async function checkHybridAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
try {
// 1. Check for internal JWT token first
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
if (verification.valid) {
return resolveUserFromJwt(verification.userId || null, options)
}
}
// 2. Try session auth (for web UI)
const session = await getSession()
if (session?.user?.id) {
return {
success: true,
userId: session.user.id,
userName: session.user.name,
userEmail: session.user.email,
authType: AuthType.SESSION,
}
}
// 3. Try API key auth (X-API-Key header only)
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
if (result.success) {
await updateApiKeyLastUsed(result.keyId!)
return {
success: true,
userId: result.userId!,
workspaceId: result.workspaceId,
authType: AuthType.API_KEY,
apiKeyType: result.keyType,
}
}
return {
success: false,
error: 'Invalid API key',
}
}
// No authentication found
return {
success: false,
error: 'Unauthorized',
}
} catch (error) {
logger.error('Error in hybrid authentication:', error)
return {
success: false,
error: 'Authentication error',
}
}
}
+1
View File
@@ -0,0 +1 @@
export { auth, getSession } from './auth'
+105
View File
@@ -0,0 +1,105 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { getClientIp } from '@/lib/core/utils/request'
const logger = createLogger('CronAuth')
const getJwtSecret = () => {
// Prefer a dedicated JWT signing key so the internal-JWT trust domain is
// separable from the raw INTERNAL_API_SECRET shared-bearer secret: leaking one
// shouldn't grant the other (raw secret => call internal endpoints; JWT key =>
// mint tokens for arbitrary userIds). Falls back to INTERNAL_API_SECRET when
// unset so existing deployments keep working until the key is rotated in.
const secret = new TextEncoder().encode(env.INTERNAL_JWT_SECRET || env.INTERNAL_API_SECRET)
return secret
}
/**
* Generate an internal JWT token for server-side API calls
* Token expires in 5 minutes to keep it short-lived
* @param userId Optional user ID to embed in token payload
*/
export async function generateInternalToken(userId?: string): Promise<string> {
const secret = getJwtSecret()
const payload: { type: string; userId?: string } = { type: 'internal' }
if (userId) {
payload.userId = userId
}
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.setIssuer('sim-internal')
.setAudience('sim-api')
.sign(secret)
return token
}
/**
* Verify an internal JWT token
* Returns verification result with userId if present in token
*/
export async function verifyInternalToken(
token: string
): Promise<{ valid: boolean; userId?: string }> {
try {
const secret = getJwtSecret()
const { payload } = await jwtVerify(token, secret, {
issuer: 'sim-internal',
audience: 'sim-api',
})
// Check that it's an internal token
if (payload.type === 'internal') {
return {
valid: true,
userId: typeof payload.userId === 'string' ? payload.userId : undefined,
}
}
return { valid: false }
} catch (error) {
// Token verification failed
return { valid: false }
}
}
/**
* Verify CRON authentication for scheduled API endpoints
* Returns null if authorized, or a NextResponse with error if unauthorized
*/
export function verifyCronAuth(request: NextRequest, context?: string): NextResponse | null {
if (!env.CRON_SECRET) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`CRON endpoint accessed but CRON_SECRET is not configured${contextInfo}`, {
ip: getClientIp(request),
userAgent: request.headers.get('user-agent') ?? 'unknown',
context,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const authHeader = request.headers.get('authorization')
const expectedAuth = `Bearer ${env.CRON_SECRET}`
const isValid = authHeader !== null && safeCompare(authHeader, expectedAuth)
if (!isValid) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
hasAuthorizationHeader: authHeader !== null,
ip: getClientIp(request),
userAgent: request.headers.get('user-agent') ?? 'unknown',
context,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return null
}
@@ -0,0 +1,31 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
describe('extractSessionDataFromAuthClientResult', () => {
it('returns null for non-objects', () => {
expect(extractSessionDataFromAuthClientResult(null)).toBeNull()
expect(extractSessionDataFromAuthClientResult(undefined)).toBeNull()
expect(extractSessionDataFromAuthClientResult('nope')).toBeNull()
expect(extractSessionDataFromAuthClientResult(123)).toBeNull()
})
it('prefers .data when present', () => {
expect(extractSessionDataFromAuthClientResult({ data: null })).toBeNull()
const session = { user: { id: 'u1' }, session: { id: 's1' } }
expect(extractSessionDataFromAuthClientResult({ data: session })).toEqual(session)
})
it('falls back to raw session payload shape', () => {
const raw = { user: { id: 'u1' }, session: { id: 's1' } }
expect(extractSessionDataFromAuthClientResult(raw)).toEqual(raw)
})
it('returns null for unknown object shapes', () => {
expect(extractSessionDataFromAuthClientResult({})).toBeNull()
expect(extractSessionDataFromAuthClientResult({ ok: true })).toBeNull()
})
})
+43
View File
@@ -0,0 +1,43 @@
/**
* The app-facing session shape derived from the Better Auth client response.
* Lives here (the module that produces it) so both the `useSessionQuery` hook
* and the `SessionProvider` can import it without a provider ↔ hook import cycle.
*/
export type AppSession = {
user: {
id: string
email: string
emailVerified?: boolean
name?: string | null
image?: string | null
role?: string
createdAt?: Date
updatedAt?: Date
} | null
session?: {
id?: string
userId?: string
activeOrganizationId?: string
impersonatedBy?: string | null
}
} | null
export function extractSessionDataFromAuthClientResult(result: unknown): unknown | null {
if (!result || typeof result !== 'object') {
return null
}
const record = result as Record<string, unknown>
// Expected shape from better-auth client: { data: <session> }
if ('data' in record) {
return (record as { data?: unknown }).data ?? null
}
// Fallback for raw session payloads: { user, session }
if ('user' in record) {
return record
}
return null
}
+44
View File
@@ -0,0 +1,44 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { normalizeSSODomain } from '@/lib/auth/sso/domain'
describe('normalizeSSODomain', () => {
it('lowercases and trims', () => {
expect(normalizeSSODomain(' Company.COM ')).toBe('company.com')
})
it('strips protocol, path, query, and port', () => {
expect(normalizeSSODomain('https://company.com/sso?x=1')).toBe('company.com')
expect(normalizeSSODomain('company.com:8443')).toBe('company.com')
})
it('strips wildcard, leading @, and email local part', () => {
expect(normalizeSSODomain('*.company.com')).toBe('company.com')
expect(normalizeSSODomain('@company.com')).toBe('company.com')
expect(normalizeSSODomain('user@company.com')).toBe('company.com')
})
it('drops a trailing dot', () => {
expect(normalizeSSODomain('company.com.')).toBe('company.com')
})
it('treats casing and formatting variants as the same domain', () => {
expect(normalizeSSODomain('Company.COM')).toBe(normalizeSSODomain('company.com'))
expect(normalizeSSODomain('user@Company.com')).toBe(normalizeSSODomain('company.com'))
})
it('rejects values that are not registrable domains', () => {
expect(normalizeSSODomain('')).toBeNull()
expect(normalizeSSODomain('localhost')).toBeNull()
expect(normalizeSSODomain('not a domain')).toBeNull()
expect(normalizeSSODomain('company')).toBeNull()
})
it('rejects bare IP addresses and numeric TLDs', () => {
expect(normalizeSSODomain('10.0.0.1')).toBeNull()
expect(normalizeSSODomain('192.168.1.1')).toBeNull()
expect(normalizeSSODomain('company.123')).toBeNull()
})
})
+28
View File
@@ -0,0 +1,28 @@
/**
* Normalizes a user-supplied SSO email domain to a canonical, comparable form:
* strips protocol, path, query, port, a leading wildcard/`@`, an email local
* part, and a trailing dot, then lowercases. Returns `null` for inputs that are
* not a registrable domain (e.g. `example.com`), which callers treat as invalid.
*/
export function normalizeSSODomain(input: string): string | null {
if (typeof input !== 'string') return null
let value = input.trim().toLowerCase()
if (!value) return null
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
value = value.replace(/^\*\./, '').replace(/^@/, '')
value = value.split('/')[0]
value = value.split('?')[0]
value = value.split('@').pop() ?? value
value = value.split(':')[0]
value = value.replace(/\.$/, '')
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value)) return null
const labels = value.split('.')
if (labels.some((label) => label.length === 0 || label.length > 63)) return null
if (/^\d+$/.test(labels[labels.length - 1])) return null
return value
}