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
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { auth, getSession } from './auth'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user