mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
feat(files): password, email-OTP, and SSO auth for public file shares (#5140)
* feat(files): password, email-OTP, and SSO auth for public file shares * fix(files): suppress filename in share previews for email/sso, not just password * fix(files): normalize allow-list emails to lowercase; genericize shared SSO denial message * fix(security): make isEmailAllowed case-insensitive; normalize email at client gates * test(security): cover isEmailAllowed case-insensitive matching * fix(security): bind auth cookie to auth type; password endpoint rejects non-password shares * chore(db): format generated migration meta * fix(files): share upsert validation returns 400 not 500; disabling always succeeds * feat(access-control): org admins can restrict allowed file-share auth types
This commit is contained in:
@@ -366,7 +366,7 @@ export const GET = withRouteHandler(
|
||||
deployment.authType !== 'public' &&
|
||||
deployment.authType !== 'sso' &&
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.password)
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password)
|
||||
) {
|
||||
return createSuccessResponse(toChatConfigResponse(deployment))
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock('@/lib/core/security/deployment', () => ({
|
||||
validateAuthToken: mockValidateAuthToken,
|
||||
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
|
||||
isEmailAllowed: mockIsEmailAllowed,
|
||||
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
@@ -134,6 +135,7 @@ describe('Chat API Utils', () => {
|
||||
expect(mockValidateAuthToken).toHaveBeenCalledWith(
|
||||
'valid-token',
|
||||
'chat-id',
|
||||
'password',
|
||||
'encrypted-password'
|
||||
)
|
||||
expect(result.authorized).toBe(true)
|
||||
@@ -407,7 +409,7 @@ describe('Chat API Utils', () => {
|
||||
})
|
||||
|
||||
expect(result.authorized).toBe(false)
|
||||
expect(result.error).toBe('Your email is not authorized to access this chat')
|
||||
expect(result.error).toBe('Your email is not authorized to access this resource')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+10
-156
@@ -2,37 +2,20 @@ import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
|
||||
import {
|
||||
isEmailAllowed,
|
||||
setDeploymentAuthCookie,
|
||||
validateAuthToken,
|
||||
} from '@/lib/core/security/deployment'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getClientIp } from '@/lib/core/utils/request'
|
||||
type DeploymentAuthResult,
|
||||
validateDeploymentAuth,
|
||||
} from '@/lib/core/security/deployment-auth'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatAuthUtils')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
/**
|
||||
* Throttles unauthenticated password guesses per client IP against a single
|
||||
* deployment, mirroring the OTP/SSO IP limits.
|
||||
*/
|
||||
const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 10,
|
||||
refillRate: 10,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
export function setChatAuthCookie(
|
||||
response: NextResponse,
|
||||
chatId: string,
|
||||
@@ -157,144 +140,15 @@ export async function checkChatAccess(
|
||||
: { hasAccess: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates auth for a deployed chat. Thin wrapper over the shared
|
||||
* {@link validateDeploymentAuth} with the `'chat'` cookie/rate-limit namespace.
|
||||
*/
|
||||
export async function validateChatAuth(
|
||||
requestId: string,
|
||||
deployment: any,
|
||||
request: NextRequest,
|
||||
parsedBody?: any
|
||||
): Promise<{ authorized: boolean; error?: string; status?: number; retryAfterMs?: number }> {
|
||||
const authType = deployment.authType || 'public'
|
||||
|
||||
if (authType === 'public') {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
if (authType !== 'sso') {
|
||||
const cookieName = `chat_auth_${deployment.id}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
|
||||
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
|
||||
return { authorized: true }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'password') {
|
||||
if (request.method === 'GET') {
|
||||
return { authorized: false, error: 'auth_required_password' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!parsedBody) {
|
||||
return { authorized: false, error: 'Password is required' }
|
||||
}
|
||||
|
||||
const { password, input } = parsedBody
|
||||
|
||||
if (input && !password) {
|
||||
return { authorized: false, error: 'auth_required_password' }
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return { authorized: false, error: 'Password is required' }
|
||||
}
|
||||
|
||||
if (!deployment.password) {
|
||||
logger.error(`[${requestId}] No password set for password-protected chat: ${deployment.id}`)
|
||||
return { authorized: false, error: 'Authentication configuration error' }
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-password:ip:${deployment.id}:${ip}`,
|
||||
PASSWORD_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] Password attempt IP rate limit exceeded for chat ${deployment.id} from ${ip}`
|
||||
)
|
||||
return {
|
||||
authorized: false,
|
||||
error: 'Too many attempts. Please try again later.',
|
||||
status: 429,
|
||||
retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs,
|
||||
}
|
||||
}
|
||||
|
||||
const { decrypted } = await decryptSecret(deployment.password)
|
||||
if (!safeCompare(password, decrypted)) {
|
||||
return { authorized: false, error: 'Invalid password' }
|
||||
}
|
||||
|
||||
return { authorized: true }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating password:`, error)
|
||||
return { authorized: false, error: 'Authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'email') {
|
||||
if (request.method === 'GET') {
|
||||
return { authorized: false, error: 'auth_required_email' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!parsedBody) {
|
||||
return { authorized: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
const { email, input } = parsedBody
|
||||
|
||||
if (input && !email) {
|
||||
return { authorized: false, error: 'auth_required_email' }
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return { authorized: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
const allowedEmails = deployment.allowedEmails || []
|
||||
|
||||
if (isEmailAllowed(email, allowedEmails)) {
|
||||
return { authorized: false, error: 'otp_required' }
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Email not authorized' }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating email:`, error)
|
||||
return { authorized: false, error: 'Authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'sso') {
|
||||
try {
|
||||
if (request.method !== 'GET' && !parsedBody) {
|
||||
return { authorized: false, error: 'SSO authentication is required' }
|
||||
}
|
||||
|
||||
const { getSession } = await import('@/lib/auth')
|
||||
const session = await getSession()
|
||||
|
||||
if (!session || !session.user) {
|
||||
return { authorized: false, error: 'auth_required_sso' }
|
||||
}
|
||||
|
||||
const userEmail = session.user.email
|
||||
if (!userEmail) {
|
||||
return { authorized: false, error: 'SSO session does not contain email' }
|
||||
}
|
||||
|
||||
const allowedEmails = deployment.allowedEmails || []
|
||||
|
||||
if (isEmailAllowed(userEmail, allowedEmails)) {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Your email is not authorized to access this chat' }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating SSO:`, error)
|
||||
return { authorized: false, error: 'SSO authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Unsupported authentication type' }
|
||||
): Promise<DeploymentAuthResult> {
|
||||
return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockResolveActiveShareByToken,
|
||||
mockEnforceRateLimit,
|
||||
mockValidateDeploymentAuth,
|
||||
mockDownloadFile,
|
||||
mockResolveServableDoc,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockEnforceRateLimit: vi.fn(),
|
||||
mockValidateDeploymentAuth: vi.fn(),
|
||||
mockDownloadFile: vi.fn(),
|
||||
mockResolveServableDoc: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveActiveShareByToken,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/rate-limit', () => ({
|
||||
enforcePublicFileRateLimit: mockEnforceRateLimit,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment-auth', () => ({
|
||||
validateDeploymentAuth: mockValidateDeploymentAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
downloadFile: mockDownloadFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({
|
||||
resolveServableDoc: mockResolveServableDoc,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/files/public/[token]/content/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const request = (token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/content`)
|
||||
|
||||
const passwordShare = {
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' },
|
||||
file: {
|
||||
id: 'wf_1',
|
||||
key: 'workspace/ws/secret-key.pdf',
|
||||
workspaceId: 'ws-1',
|
||||
originalName: 'report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 4,
|
||||
},
|
||||
workspaceName: 'Acme',
|
||||
ownerName: 'Jane',
|
||||
}
|
||||
|
||||
describe('GET /api/files/public/[token]/content', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnforceRateLimit.mockResolvedValue(null)
|
||||
mockResolveActiveShareByToken.mockResolvedValue(passwordShare)
|
||||
mockDownloadFile.mockResolvedValue(Buffer.from('data'))
|
||||
mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' })
|
||||
})
|
||||
|
||||
it('returns 401 and never reads storage when a password share is unauthorized', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'auth_required_password',
|
||||
})
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).error).toBe('auth_required_password')
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves the bytes once authorized', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith({
|
||||
key: passwordShare.file.key,
|
||||
context: 'workspace',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'
|
||||
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile'
|
||||
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
@@ -28,6 +30,8 @@ const logger = createLogger('PublicFileContentAPI')
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const limited = await enforcePublicFileRateLimit(request, 'content')
|
||||
if (limited) return limited
|
||||
@@ -41,6 +45,17 @@ export const GET = withRouteHandler(
|
||||
throw new FileNotFoundError('Not found')
|
||||
}
|
||||
|
||||
const auth = await validateDeploymentAuth(
|
||||
requestId,
|
||||
resolved.share,
|
||||
request,
|
||||
undefined,
|
||||
'file'
|
||||
)
|
||||
if (!auth.authorized) {
|
||||
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { file } = resolved
|
||||
const raw = await downloadFile({ key: file.key, context: 'workspace' })
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockResolveActiveShareByToken,
|
||||
mockIsEmailAllowed,
|
||||
mockSetDeploymentAuthCookie,
|
||||
mockGenerateOTP,
|
||||
mockStoreOTP,
|
||||
mockGetOTP,
|
||||
mockDeleteOTP,
|
||||
mockIncrementOTPAttempts,
|
||||
mockDecodeOTPValue,
|
||||
mockRenderOTPEmail,
|
||||
mockSendEmail,
|
||||
mockCheckRateLimitDirect,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
mockGenerateOTP: vi.fn(),
|
||||
mockStoreOTP: vi.fn(),
|
||||
mockGetOTP: vi.fn(),
|
||||
mockDeleteOTP: vi.fn(),
|
||||
mockIncrementOTPAttempts: vi.fn(),
|
||||
mockDecodeOTPValue: vi.fn(),
|
||||
mockRenderOTPEmail: vi.fn(),
|
||||
mockSendEmail: vi.fn(),
|
||||
mockCheckRateLimitDirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveActiveShareByToken,
|
||||
}))
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
isEmailAllowed: mockIsEmailAllowed,
|
||||
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
|
||||
}))
|
||||
vi.mock('@/lib/core/security/otp', () => ({
|
||||
generateOTP: mockGenerateOTP,
|
||||
storeOTP: mockStoreOTP,
|
||||
getOTP: mockGetOTP,
|
||||
deleteOTP: mockDeleteOTP,
|
||||
incrementOTPAttempts: mockIncrementOTPAttempts,
|
||||
decodeOTPValue: mockDecodeOTPValue,
|
||||
MAX_OTP_ATTEMPTS: 5,
|
||||
OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 },
|
||||
OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 },
|
||||
}))
|
||||
vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail }))
|
||||
vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail }))
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = mockCheckRateLimitDirect
|
||||
},
|
||||
}))
|
||||
|
||||
import { POST, PUT } from '@/app/api/files/public/[token]/otp/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const post = (email: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const put = (email: string, otp: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/otp`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, otp }),
|
||||
})
|
||||
|
||||
const emailShare = {
|
||||
share: { id: 'sh_1', authType: 'email', password: null, allowedEmails: ['@acme.com'] },
|
||||
file: { originalName: 'report.pdf' },
|
||||
}
|
||||
|
||||
describe('POST /api/files/public/[token]/otp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
|
||||
mockResolveActiveShareByToken.mockResolvedValue(emailShare)
|
||||
mockIsEmailAllowed.mockReturnValue(true)
|
||||
mockGenerateOTP.mockReturnValue('123456')
|
||||
mockRenderOTPEmail.mockResolvedValue('<html/>')
|
||||
mockSendEmail.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('sends a code to an allow-listed email', async () => {
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockStoreOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com', '123456')
|
||||
expect(mockSendEmail).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an email not on the allow-list with 403', async () => {
|
||||
mockIsEmailAllowed.mockReturnValueOnce(false)
|
||||
const res = await POST(post('user@evil.com'), params())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockStoreOTP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lowercases the email for allow-list matching and OTP storage', async () => {
|
||||
await POST(post('User@ACME.com'), params())
|
||||
expect(mockIsEmailAllowed).toHaveBeenCalledWith('user@acme.com', expect.anything())
|
||||
expect(mockStoreOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com', '123456')
|
||||
})
|
||||
|
||||
it('rejects a non-email share with 400', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce({
|
||||
...emailShare,
|
||||
share: { ...emailShare.share, authType: 'password' },
|
||||
})
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 429 when the IP rate limit is exceeded', async () => {
|
||||
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 })
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/files/public/[token]/otp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveActiveShareByToken.mockResolvedValue(emailShare)
|
||||
mockGetOTP.mockResolvedValue('123456:0')
|
||||
mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 })
|
||||
})
|
||||
|
||||
it('verifies a correct code, sets the cookie, returns authType', async () => {
|
||||
const res = await PUT(put('user@acme.com', '123456'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ authType: 'email' })
|
||||
expect(mockDeleteOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com')
|
||||
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'file',
|
||||
'sh_1',
|
||||
'email',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a wrong code with 400 and increments attempts', async () => {
|
||||
mockIncrementOTPAttempts.mockResolvedValueOnce('incremented')
|
||||
const res = await PUT(put('user@acme.com', '000000'), params())
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockIncrementOTPAttempts).toHaveBeenCalled()
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 429 when attempts are exhausted on a wrong code', async () => {
|
||||
mockIncrementOTPAttempts.mockResolvedValueOnce('locked')
|
||||
const res = await PUT(put('user@acme.com', '000000'), params())
|
||||
expect(res.status).toBe(429)
|
||||
})
|
||||
|
||||
it('returns 400 when no code was issued', async () => {
|
||||
mockGetOTP.mockResolvedValueOnce(null)
|
||||
const res = await PUT(put('user@acme.com', '123456'), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderOTPEmail } from '@/components/emails'
|
||||
import {
|
||||
requestPublicFileOtpContract,
|
||||
verifyPublicFileOtpContract,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed, setDeploymentAuthCookie } from '@/lib/core/security/deployment'
|
||||
import {
|
||||
decodeOTPValue,
|
||||
deleteOTP,
|
||||
generateOTP,
|
||||
getOTP,
|
||||
incrementOTPAttempts,
|
||||
MAX_OTP_ATTEMPTS,
|
||||
OTP_EMAIL_RATE_LIMIT,
|
||||
OTP_IP_RATE_LIMIT,
|
||||
storeOTP,
|
||||
} from '@/lib/core/security/otp'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('PublicFileOtpAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const SHARE_EMAIL_LABEL = 'a shared file'
|
||||
|
||||
/** Allow-list for an email-gated share, read off the resolved row. */
|
||||
function shareAllowedEmails(allowedEmails: unknown): string[] {
|
||||
return Array.isArray(allowedEmails) ? (allowedEmails as string[]) : []
|
||||
}
|
||||
|
||||
function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse {
|
||||
const response = NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000)))
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/files/public/[token]/otp
|
||||
* Sends a 6-digit verification code to an allow-listed email for an email-gated share.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:ip:${ip}`,
|
||||
OTP_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`)
|
||||
return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(requestPublicFileOtpContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
// Normalize once so allow-list matching, OTP storage, and the verify lookup
|
||||
// all key off the same value (allow-list entries are stored lowercase).
|
||||
const email = parsed.data.body.email.trim().toLowerCase()
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
if (resolved.share.authType !== 'email') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This file does not use email authentication' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) {
|
||||
return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 })
|
||||
}
|
||||
|
||||
const emailRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-otp:email:${resolved.share.id}:${email}`,
|
||||
OTP_EMAIL_RATE_LIMIT
|
||||
)
|
||||
if (!emailRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`)
|
||||
return rateLimited(emailRateLimit.retryAfterMs, OTP_EMAIL_RATE_LIMIT.refillIntervalMs)
|
||||
}
|
||||
|
||||
const otp = generateOTP()
|
||||
await storeOTP('file', resolved.share.id, email, otp)
|
||||
|
||||
const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL)
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: `Verification code for ${SHARE_EMAIL_LABEL}`,
|
||||
html: emailHtml,
|
||||
})
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
|
||||
return NextResponse.json({ error: 'Failed to send verification email' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] OTP sent for share ${resolved.share.id}`)
|
||||
return NextResponse.json({ message: 'Verification code sent' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing OTP request:`, error)
|
||||
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/files/public/[token]/otp
|
||||
* Verifies the code and, on success, sets the `file_auth_{shareId}` cookie.
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(verifyPublicFileOtpContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const { otp } = parsed.data.body
|
||||
const email = parsed.data.body.email.trim().toLowerCase()
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
if (resolved.share.authType !== 'email') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This file does not use email authentication' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const storedValue = await getOTP('file', resolved.share.id, email)
|
||||
if (!storedValue) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No verification code found, request a new one' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
|
||||
if (attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await deleteOTP('file', resolved.share.id, email)
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many failed attempts. Please request a new code.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
if (storedOTP !== otp) {
|
||||
const result = await incrementOTPAttempts('file', resolved.share.id, email, storedValue)
|
||||
if (result === 'locked') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many failed attempts. Please request a new code.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invalid verification code' }, { status: 400 })
|
||||
}
|
||||
|
||||
await deleteOTP('file', resolved.share.id, email)
|
||||
|
||||
const response = NextResponse.json({ authType: resolved.share.authType })
|
||||
setDeploymentAuthCookie(
|
||||
response,
|
||||
'file',
|
||||
resolved.share.id,
|
||||
resolved.share.authType,
|
||||
resolved.share.password
|
||||
)
|
||||
logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`)
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error verifying OTP:`, error)
|
||||
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -4,9 +4,16 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockResolveActiveShareByToken, mockEnforceRateLimit } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockResolveActiveShareByToken,
|
||||
mockEnforceRateLimit,
|
||||
mockValidateDeploymentAuth,
|
||||
mockSetDeploymentAuthCookie,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockEnforceRateLimit: vi.fn(),
|
||||
mockValidateDeploymentAuth: vi.fn(),
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
@@ -17,16 +24,50 @@ vi.mock('@/lib/public-shares/rate-limit', () => ({
|
||||
enforcePublicFileRateLimit: mockEnforceRateLimit,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment-auth', () => ({
|
||||
validateDeploymentAuth: mockValidateDeploymentAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/deployment', () => ({
|
||||
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
|
||||
}))
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { GET } from '@/app/api/files/public/[token]/route'
|
||||
import { GET, POST } from '@/app/api/files/public/[token]/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`)
|
||||
const postRequest = (password: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
const publicShare = {
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null },
|
||||
file: {
|
||||
id: 'wf_1',
|
||||
key: 'workspace/ws/secret-key.pdf',
|
||||
workspaceId: 'ws-secret',
|
||||
originalName: 'report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 2048,
|
||||
},
|
||||
workspaceName: 'Acme Workspace',
|
||||
ownerName: 'Jane Doe',
|
||||
}
|
||||
|
||||
const passwordShare = {
|
||||
...publicShare,
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' },
|
||||
}
|
||||
|
||||
describe('GET /api/files/public/[token]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnforceRateLimit.mockResolvedValue(null) // allow by default
|
||||
mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default
|
||||
})
|
||||
|
||||
it('returns 429 when the per-IP rate limit is exceeded', async () => {
|
||||
@@ -44,20 +85,8 @@ describe('GET /api/files/public/[token]', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns public-safe metadata (name/type/size + provenance) without leaking the key or workspace id', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce({
|
||||
share: { id: 'sh_1', token: 'tok_1' },
|
||||
file: {
|
||||
id: 'wf_1',
|
||||
key: 'workspace/ws/secret-key.pdf',
|
||||
workspaceId: 'ws-secret',
|
||||
originalName: 'report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 2048,
|
||||
},
|
||||
workspaceName: 'Acme Workspace',
|
||||
ownerName: 'Jane Doe',
|
||||
})
|
||||
it('returns public-safe metadata without leaking the key or workspace id', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(publicShare)
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
@@ -72,4 +101,92 @@ describe('GET /api/files/public/[token]', () => {
|
||||
expect(JSON.stringify(body)).not.toContain('secret-key')
|
||||
expect(JSON.stringify(body)).not.toContain('ws-secret')
|
||||
})
|
||||
|
||||
it('returns 401 auth_required_password for a password share without a valid cookie', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare)
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'auth_required_password',
|
||||
})
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).error).toBe('auth_required_password')
|
||||
expect(mockValidateDeploymentAuth).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
passwordShare.share,
|
||||
expect.anything(),
|
||||
undefined,
|
||||
'file'
|
||||
)
|
||||
})
|
||||
|
||||
it('serves metadata for a password share once authorized by cookie', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare)
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
|
||||
const res = await GET(request(), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).name).toBe('report.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/files/public/[token]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveActiveShareByToken.mockResolvedValue(passwordShare)
|
||||
})
|
||||
|
||||
it('sets the file_auth cookie and returns the authType on a correct password', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
|
||||
const res = await POST(postRequest('hunter2'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ authType: 'password' })
|
||||
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'file',
|
||||
'sh_1',
|
||||
'password',
|
||||
'enc:secret'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to mint a cookie for a non-password (e.g. public) share', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce({
|
||||
...passwordShare,
|
||||
share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null },
|
||||
})
|
||||
const res = await POST(postRequest('whatever'), params())
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockValidateDeploymentAuth).not.toHaveBeenCalled()
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 401 Invalid password on mismatch without setting a cookie', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'Invalid password',
|
||||
})
|
||||
const res = await POST(postRequest('wrong'), params())
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).error).toBe('Invalid password')
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 429 with Retry-After when password attempts are rate-limited', async () => {
|
||||
mockValidateDeploymentAuth.mockResolvedValueOnce({
|
||||
authorized: false,
|
||||
error: 'Too many attempts. Please try again later.',
|
||||
status: 429,
|
||||
retryAfterMs: 60_000,
|
||||
})
|
||||
const res = await POST(postRequest('wrong'), params())
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('60')
|
||||
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown token', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
|
||||
const res = await POST(postRequest('hunter2'), params())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,14 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPublicFileContract } from '@/lib/api/contracts/public-shares'
|
||||
import {
|
||||
authenticatePublicFileContract,
|
||||
getPublicFileContract,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
|
||||
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
@@ -15,10 +21,14 @@ const logger = createLogger('PublicFileMetadataAPI')
|
||||
/**
|
||||
* GET /api/files/public/[token]
|
||||
* Public, unauthenticated metadata for a shared file. Returns 404 for unknown,
|
||||
* inactive, or deleted shares — the existence of a file is never leaked.
|
||||
* inactive, or deleted shares — the existence of a file is never leaked. A
|
||||
* password-protected share returns 401 `auth_required_password` until a valid
|
||||
* `file_auth_{shareId}` cookie is present.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const limited = await enforcePublicFileRateLimit(request, 'metadata')
|
||||
if (limited) return limited
|
||||
@@ -32,6 +42,17 @@ export const GET = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const auth = await validateDeploymentAuth(
|
||||
requestId,
|
||||
resolved.share,
|
||||
request,
|
||||
undefined,
|
||||
'file'
|
||||
)
|
||||
if (!auth.authorized) {
|
||||
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { file, workspaceName, ownerName } = resolved
|
||||
return NextResponse.json({
|
||||
token,
|
||||
@@ -50,3 +71,73 @@ export const GET = withRouteHandler(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/files/public/[token]
|
||||
* Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited
|
||||
* via the shared deployment-auth gate; returns 401 (`Invalid password`) on
|
||||
* mismatch and 429 (with `Retry-After`) when throttled.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(authenticatePublicFileContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const { password } = parsed.data.body
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// This endpoint authenticates password shares only. Refusing other modes
|
||||
// here prevents minting a `file_auth` cookie for a `public` share (which
|
||||
// `validateDeploymentAuth` would otherwise authorize), which could later
|
||||
// satisfy the gate if the share is switched to `email`/`sso`.
|
||||
if (resolved.share.authType !== 'password') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This file does not use password authentication' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const auth = await validateDeploymentAuth(
|
||||
requestId,
|
||||
resolved.share,
|
||||
request,
|
||||
{ password },
|
||||
'file'
|
||||
)
|
||||
if (!auth.authorized) {
|
||||
const response = NextResponse.json(
|
||||
{ error: auth.error ?? 'Invalid password' },
|
||||
{ status: auth.status ?? 401 }
|
||||
)
|
||||
if (auth.status === 429 && auth.retryAfterMs !== undefined) {
|
||||
response.headers.set('Retry-After', String(Math.ceil(auth.retryAfterMs / 1000)))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ authType: resolved.share.authType })
|
||||
setDeploymentAuthCookie(
|
||||
response,
|
||||
'file',
|
||||
resolved.share.id,
|
||||
resolved.share.authType,
|
||||
resolved.share.password
|
||||
)
|
||||
logger.info('Public file share password accepted', { token, shareId: resolved.share.id })
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error authenticating public file share:', error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to authenticate') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockResolveActiveShareByToken, mockIsEmailAllowed, mockCheckRateLimitDirect } = vi.hoisted(
|
||||
() => ({
|
||||
mockResolveActiveShareByToken: vi.fn(),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
mockCheckRateLimitDirect: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
resolveActiveShareByToken: mockResolveActiveShareByToken,
|
||||
}))
|
||||
vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed }))
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: class {
|
||||
checkRateLimitDirect = mockCheckRateLimitDirect
|
||||
},
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/files/public/[token]/sso/route'
|
||||
|
||||
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
|
||||
const post = (email: string, token = 'tok_1') =>
|
||||
new NextRequest(`http://localhost/api/files/public/${token}/sso`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
const ssoShare = {
|
||||
share: { id: 'sh_1', authType: 'sso', password: null, allowedEmails: ['@acme.com'] },
|
||||
file: { originalName: 'report.pdf' },
|
||||
}
|
||||
|
||||
describe('POST /api/files/public/[token]/sso', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true })
|
||||
mockResolveActiveShareByToken.mockResolvedValue(ssoShare)
|
||||
})
|
||||
|
||||
it('returns eligible:true for an allow-listed email', async () => {
|
||||
mockIsEmailAllowed.mockReturnValueOnce(true)
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ eligible: true })
|
||||
})
|
||||
|
||||
it('returns eligible:false for a non-listed email', async () => {
|
||||
mockIsEmailAllowed.mockReturnValueOnce(false)
|
||||
const res = await POST(post('user@evil.com'), params())
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ eligible: false })
|
||||
})
|
||||
|
||||
it('rejects a non-sso share with 400', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce({
|
||||
...ssoShare,
|
||||
share: { ...ssoShare.share, authType: 'email' },
|
||||
})
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown token', async () => {
|
||||
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 429 when rate-limited', async () => {
|
||||
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 2000 })
|
||||
const res = await POST(post('user@acme.com'), params())
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const logger = createLogger('PublicFileSSOAPI')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 20,
|
||||
refillRate: 20,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/files/public/[token]/sso
|
||||
* Reports whether an email is on the allow-list for an SSO-gated share. The actual
|
||||
* authentication is the global Sim session (checked at the page/route gate).
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`file-sso:ip:${ip}`,
|
||||
SSO_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
|
||||
const response = NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
response.headers.set(
|
||||
'Retry-After',
|
||||
String(Math.ceil((ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000))
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(publicFileSSOContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { token } = parsed.data.params
|
||||
const email = parsed.data.body.email.trim().toLowerCase()
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
if (resolved.share.authType !== 'sso') {
|
||||
return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 })
|
||||
}
|
||||
|
||||
const allowedEmails = Array.isArray(resolved.share.allowedEmails)
|
||||
? (resolved.share.allowedEmails as string[])
|
||||
: []
|
||||
return NextResponse.json({ eligible: isEmailAllowed(email, allowedEmails) })
|
||||
}
|
||||
)
|
||||
@@ -43,7 +43,10 @@ async function validateChatAuth(request: NextRequest, chatId: string): Promise<b
|
||||
const cookieName = `chat_auth_${chatId}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
|
||||
if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) {
|
||||
if (
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, chatId, chatData.authType, chatData.password)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,10 @@ async function validateChatAuth(
|
||||
|
||||
const cookieName = `chat_auth_${chatId}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) {
|
||||
if (
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, chatId, chatData.authType, chatData.password)
|
||||
) {
|
||||
return { valid: true, ownerId: chatData.userId, workspaceId: chatData.workspaceId }
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,19 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({
|
||||
getWorkspaceFile: mockGetWorkspaceFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/public-shares/share-manager', () => ({
|
||||
getShareForResource: mockGetShareForResource,
|
||||
upsertFileShare: mockUpsertFileShare,
|
||||
}))
|
||||
vi.mock('@/lib/public-shares/share-manager', () => {
|
||||
class ShareValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ShareValidationError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
getShareForResource: mockGetShareForResource,
|
||||
upsertFileShare: mockUpsertFileShare,
|
||||
ShareValidationError,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/ee/access-control/utils/permission-check', () => {
|
||||
class PublicFileSharingNotAllowedError extends Error {
|
||||
@@ -38,6 +47,7 @@ vi.mock('@sim/audit', () => auditMock)
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
const FILE_ID = 'wf_abc'
|
||||
|
||||
import { ShareValidationError } from '@/lib/public-shares/share-manager'
|
||||
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
|
||||
|
||||
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
|
||||
@@ -102,6 +112,15 @@ describe('share route', () => {
|
||||
expect(mockUpsertFileShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a ShareValidationError to 400, not 500', async () => {
|
||||
mockUpsertFileShare.mockRejectedValueOnce(
|
||||
new ShareValidationError('Password is required for password-protected shares')
|
||||
)
|
||||
const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params())
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).error).toBe('Password is required for password-protected shares')
|
||||
})
|
||||
|
||||
it('returns 404 when the file is not in the workspace', async () => {
|
||||
mockGetWorkspaceFile.mockResolvedValueOnce(null)
|
||||
const res = await PUT(putRequest({ isActive: true }), params())
|
||||
|
||||
@@ -7,7 +7,11 @@ import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getShareForResource, upsertFileShare } from '@/lib/public-shares/share-manager'
|
||||
import {
|
||||
getShareForResource,
|
||||
ShareValidationError,
|
||||
upsertFileShare,
|
||||
} from '@/lib/public-shares/share-manager'
|
||||
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
@@ -81,7 +85,7 @@ export const PUT = withRouteHandler(
|
||||
const parsed = await parseRequest(upsertFileShareContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId, fileId } = parsed.data.params
|
||||
const { isActive } = parsed.data.body
|
||||
const { isActive, authType, password, allowedEmails, token } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
@@ -96,11 +100,12 @@ export const PUT = withRouteHandler(
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Enabling a public link is gated by the org's access-control policy; disabling
|
||||
// is always allowed so users can still un-share after the policy is turned on.
|
||||
// Enabling a share is gated by the org's access-control policy (both the
|
||||
// master on/off and the per-auth-type allow-list); disabling is always
|
||||
// allowed so users can still un-share after the policy is turned on.
|
||||
if (isActive) {
|
||||
try {
|
||||
await validatePublicFileSharing(session.user.id, workspaceId)
|
||||
await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public')
|
||||
} catch (error) {
|
||||
if (error instanceof PublicFileSharingNotAllowedError) {
|
||||
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)
|
||||
@@ -115,6 +120,10 @@ export const PUT = withRouteHandler(
|
||||
fileId,
|
||||
userId: session.user.id,
|
||||
isActive,
|
||||
authType,
|
||||
password,
|
||||
allowedEmails,
|
||||
token,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`)
|
||||
@@ -134,6 +143,9 @@ export const PUT = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ share })
|
||||
} catch (error) {
|
||||
if (error instanceof ShareValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
logger.error(`[${requestId}] Error updating file share:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update share') },
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
import { createLandingOgImage } from '@/app/(landing)/og-utils'
|
||||
import { buildProvenance } from '@/app/f/[token]/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const contentType = 'image/png'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
/**
|
||||
* Social-preview card for a shared file. Public shares show the file name +
|
||||
* provenance; protected (password / email / SSO) and unknown shares stay generic
|
||||
* so the filename never leaks pre-auth.
|
||||
*/
|
||||
export default async function Image({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
|
||||
if (!resolved || resolved.share.authType !== 'public') {
|
||||
return createLandingOgImage({
|
||||
eyebrow: 'Shared file',
|
||||
title: 'Protected file',
|
||||
subtitle: 'Authentication is required to view this file',
|
||||
})
|
||||
}
|
||||
|
||||
const { file, workspaceName, ownerName } = resolved
|
||||
const subtitle = buildProvenance(workspaceName, ownerName) || 'Shared via Sim'
|
||||
|
||||
return createLandingOgImage({
|
||||
eyebrow: 'Shared file',
|
||||
title: file.originalName,
|
||||
subtitle,
|
||||
})
|
||||
}
|
||||
@@ -1,28 +1,116 @@
|
||||
import { cache } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
deploymentAuthCookieName,
|
||||
isEmailAllowed,
|
||||
validateAuthToken,
|
||||
} from '@/lib/core/security/deployment'
|
||||
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
|
||||
import { PublicFileAuth } from '@/app/f/[token]/public-file-auth'
|
||||
import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth'
|
||||
import { PublicFileSSOAuth } from '@/app/f/[token]/public-file-sso-auth'
|
||||
import { PublicFileView } from '@/app/f/[token]/public-file-view'
|
||||
import { buildProvenance } from '@/app/f/[token]/utils'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Deduped per-request so `generateMetadata` and the page share one DB resolve. */
|
||||
const resolveShare = cache(resolveActiveShareByToken)
|
||||
|
||||
/** Shared links must never be indexed by search engines. */
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
const NOINDEX = { index: false, follow: false } as const
|
||||
|
||||
interface PublicFilePageProps {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Social-preview metadata. Public shares unfurl with the file name + provenance;
|
||||
* any protected share (password / email / SSO) stays deliberately generic so the
|
||||
* filename never leaks before the visitor authenticates. Always `noindex`.
|
||||
*/
|
||||
export async function generateMetadata({ params }: PublicFilePageProps): Promise<Metadata> {
|
||||
const { token } = await params
|
||||
const resolved = await resolveShare(token)
|
||||
if (!resolved) {
|
||||
return { robots: NOINDEX }
|
||||
}
|
||||
|
||||
let title: string
|
||||
let description: string
|
||||
if (resolved.share.authType !== 'public') {
|
||||
title = 'Shared file'
|
||||
description = 'Authentication is required to view this file.'
|
||||
} else {
|
||||
title = resolved.file.originalName
|
||||
description =
|
||||
buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file · ${title}`
|
||||
}
|
||||
|
||||
const brand = getBrandConfig()
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
robots: NOINDEX,
|
||||
openGraph: { type: 'website', title, description, siteName: brand.name },
|
||||
twitter: { card: 'summary_large_image', title, description },
|
||||
}
|
||||
}
|
||||
|
||||
/** The auth-relevant slice of a resolved share row. */
|
||||
interface GateShare {
|
||||
id: string
|
||||
authType: string
|
||||
password: string | null
|
||||
allowedEmails: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the auth prompt to render when a protected share is not yet authorized,
|
||||
* or `null` when the visitor may view the file. `password`/`email` use the
|
||||
* `file_auth_{shareId}` cookie; `sso` uses the global Sim session.
|
||||
*/
|
||||
async function renderAuthGate(token: string, share: GateShare) {
|
||||
if (share.authType === 'public') return null
|
||||
|
||||
if (share.authType === 'sso') {
|
||||
const session = await getSession()
|
||||
const allowedEmails = Array.isArray(share.allowedEmails)
|
||||
? (share.allowedEmails as string[])
|
||||
: []
|
||||
const authorized = Boolean(
|
||||
session?.user?.email && isEmailAllowed(session.user.email, allowedEmails)
|
||||
)
|
||||
return authorized ? null : <PublicFileSSOAuth token={token} />
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value
|
||||
if (validateAuthToken(cookieValue ?? '', share.id, share.authType, share.password)) return null
|
||||
|
||||
return share.authType === 'email' ? (
|
||||
<PublicFileEmailAuth token={token} />
|
||||
) : (
|
||||
<PublicFileAuth token={token} />
|
||||
)
|
||||
}
|
||||
|
||||
export default async function PublicFilePage({ params }: PublicFilePageProps) {
|
||||
const { token } = await params
|
||||
|
||||
const resolved = await resolveActiveShareByToken(token)
|
||||
const resolved = await resolveShare(token)
|
||||
if (!resolved) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const { file, workspaceName, ownerName } = resolved
|
||||
const { share, file, workspaceName, ownerName } = resolved
|
||||
|
||||
const gate = await renderAuthGate(token, share)
|
||||
if (gate) return gate
|
||||
|
||||
return (
|
||||
<PublicFileView
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
import { SupportFooter } from '@/app/(auth)/components/support-footer'
|
||||
import Navbar from '@/app/(landing)/components/navbar/navbar'
|
||||
|
||||
interface PublicFileAuthShellProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing-chrome shell shared by the public file-share auth gates (password,
|
||||
* email OTP, SSO), matching the deployed-chat auth screens. Renders no file
|
||||
* metadata — the name/provenance are withheld until the visitor authenticates.
|
||||
*/
|
||||
export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) {
|
||||
return (
|
||||
<AuthBackground className='dark font-[430] font-season'>
|
||||
<main className='relative flex min-h-full flex-col text-[var(--landing-text)]'>
|
||||
<header className='shrink-0 bg-[var(--landing-bg)]'>
|
||||
<Navbar logoOnly />
|
||||
</header>
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='text-balance font-[430] font-season text-[40px] text-[var(--landing-text)] leading-[110%] tracking-[-0.02em]'>
|
||||
{title}
|
||||
</h1>
|
||||
<p className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className='mt-8 w-full max-w-[410px]'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SupportFooter position='absolute' />
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input, Label, Loader } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
|
||||
import { usePublicFileAuth } from '@/hooks/queries/public-shares'
|
||||
|
||||
interface PublicFileAuthProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Password gate for a protected public file share. On success the
|
||||
* `file_auth_{shareId}` cookie is set and the page re-renders the viewer.
|
||||
*/
|
||||
export function PublicFileAuth({ token }: PublicFileAuthProps) {
|
||||
const router = useRouter()
|
||||
const authenticate = usePublicFileAuth(token)
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleAuthenticate = async () => {
|
||||
if (!password.trim()) {
|
||||
setError('Password is required.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
await authenticate.mutateAsync({ password })
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Invalid password. Please try again.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicFileAuthShell title='Password Required' subtitle='This file is password-protected'>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='current-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter password'
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
className={cn(
|
||||
'pr-10',
|
||||
error && 'border-[var(--text-error)] focus:border-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!password.trim() || authenticate.isPending}
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{authenticate.isPending ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='size-4' animate />
|
||||
Authenticating…
|
||||
</span>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
|
||||
import { usePublicFileOtpRequest, usePublicFileOtpVerify } from '@/hooks/queries/public-shares'
|
||||
|
||||
interface PublicFileEmailAuthProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Email-OTP gate for a protected public file share: collect an allow-listed email,
|
||||
* send a 6-digit code, verify it. On success the server sets the
|
||||
* `file_auth_{shareId}` cookie and the page re-renders the viewer.
|
||||
*/
|
||||
export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
|
||||
const router = useRouter()
|
||||
const requestOtp = usePublicFileOtpRequest(token)
|
||||
const verifyOtp = usePublicFileOtpVerify(token)
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [otp, setOtp] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const sendCode = async () => {
|
||||
if (!quickValidateEmail(email.trim().toLowerCase()).isValid) {
|
||||
setError('Please enter a valid email address.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
await requestOtp.mutateAsync({ email: email.trim().toLowerCase() })
|
||||
setSent(true)
|
||||
setOtp('')
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to send verification code'))
|
||||
}
|
||||
}
|
||||
|
||||
const verifyCode = async (code: string) => {
|
||||
if (code.length !== 6) return
|
||||
setError(null)
|
||||
try {
|
||||
await verifyOtp.mutateAsync({ email: email.trim().toLowerCase(), otp: code })
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Invalid verification code'))
|
||||
}
|
||||
}
|
||||
|
||||
const resend = async () => {
|
||||
setCountdown(30)
|
||||
try {
|
||||
await requestOtp.mutateAsync({ email: email.trim().toLowerCase() })
|
||||
setOtp('')
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setCountdown(0)
|
||||
setError(getErrorMessage(err, 'Failed to resend verification code'))
|
||||
}
|
||||
}
|
||||
|
||||
if (!sent) {
|
||||
return (
|
||||
<PublicFileAuthShell
|
||||
title='Email Verification'
|
||||
subtitle='This file requires email verification'
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
sendCode()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
type='email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your email'
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
className={cn(error && 'border-[var(--text-error)] focus:border-[var(--text-error)]')}
|
||||
/>
|
||||
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!email.trim() || requestOtp.isPending}
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{requestOtp.isPending ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='size-4' animate />
|
||||
Sending Code…
|
||||
</span>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicFileAuthShell
|
||||
title='Verify Your Email'
|
||||
subtitle={`A verification code has been sent to ${email}`}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-[var(--landing-text-muted)] text-sm'>
|
||||
Enter the 6-digit code to verify your access. If you don't see it in your inbox, check
|
||||
your spam folder.
|
||||
</p>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={(value) => {
|
||||
setOtp(value)
|
||||
setError(null)
|
||||
if (value.length === 6) verifyCode(value)
|
||||
}}
|
||||
disabled={verifyOtp.isPending}
|
||||
className={cn('gap-2', error && 'otp-error')}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<InputOTPSlot
|
||||
key={i}
|
||||
index={i}
|
||||
className={cn(error && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
{error ? <p className='text-center text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
|
||||
<button
|
||||
onClick={() => verifyCode(otp)}
|
||||
disabled={otp.length !== 6 || verifyOtp.isPending}
|
||||
className={AUTH_SUBMIT_BTN}
|
||||
>
|
||||
{verifyOtp.isPending ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='size-4' animate />
|
||||
Verifying…
|
||||
</span>
|
||||
) : (
|
||||
'Verify Email'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className='text-center'>
|
||||
<p className='text-[var(--landing-text-muted)] text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in{' '}
|
||||
<span className='font-medium text-[var(--landing-text)]'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={AUTH_TEXT_LINK}
|
||||
onClick={resend}
|
||||
disabled={requestOtp.isPending || verifyOtp.isPending}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-center font-light text-sm'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setOtp('')
|
||||
setError(null)
|
||||
}}
|
||||
className={AUTH_TEXT_LINK}
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input, Label, Loader } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell'
|
||||
|
||||
interface PublicFileSSOAuthProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO gate for a protected public file share: confirm the email is allow-listed,
|
||||
* then hand off to the global `/sso` flow with this share as the callback. After
|
||||
* sign-in the page gate authorizes via the Sim session.
|
||||
*/
|
||||
export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleAuthenticate = async () => {
|
||||
if (!quickValidateEmail(email.trim().toLowerCase()).isValid) {
|
||||
setError('Please enter a valid email address.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const { eligible } = await requestJson(publicFileSSOContract, {
|
||||
params: { token },
|
||||
body: { email: normalizedEmail },
|
||||
})
|
||||
if (!eligible) {
|
||||
setError('Email not authorized for this file.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
const callbackUrl = `/f/${token}`
|
||||
router.push(
|
||||
`/sso?email=${encodeURIComponent(normalizedEmail)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
)
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Email not authorized for this file.'))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicFileAuthShell
|
||||
title='SSO Authentication'
|
||||
subtitle='This file requires SSO authentication'
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>Work Email</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
required
|
||||
type='email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your work email'
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
className={cn(error && 'border-[var(--text-error)] focus:border-[var(--text-error)]')}
|
||||
/>
|
||||
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<button type='submit' disabled={!email.trim() || isLoading} className={AUTH_SUBMIT_BTN}>
|
||||
{isLoading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='size-4' animate />
|
||||
Redirecting to SSO…
|
||||
</span>
|
||||
) : (
|
||||
'Continue with SSO'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</PublicFileAuthShell>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
||||
import { Chip } from '@/components/emcn'
|
||||
import { Download } from '@/components/emcn/icons'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { buildProvenance } from '@/app/f/[token]/utils'
|
||||
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
import { type FileContentSource, FileContentSourceProvider } from '@/hooks/use-file-content-source'
|
||||
@@ -32,9 +33,7 @@ export function PublicFileView({
|
||||
}: PublicFileViewProps) {
|
||||
const contentUrl = `/api/files/public/${token}/content`
|
||||
const brand = useBrandConfig()
|
||||
const provenance = [workspaceName, ownerName ? `Shared by ${ownerName}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
const provenance = buildProvenance(workspaceName, ownerName)
|
||||
|
||||
// The public viewer reuses the in-app FileViewer; the content source seam swaps
|
||||
// the auth-gated workspace serve URL for the token-scoped public endpoint, and a
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Provenance label for a shared file (`"{workspace} · Shared by {owner}"`), shared
|
||||
* by the page metadata, the OG card, and the in-page viewer so the three never
|
||||
* drift. Returns an empty string when neither is known; callers apply their own
|
||||
* fallback.
|
||||
*/
|
||||
export function buildProvenance(workspaceName: string | null, ownerName: string | null): string {
|
||||
return [workspaceName, ownerName ? `Shared by ${ownerName}` : null].filter(Boolean).join(' · ')
|
||||
}
|
||||
+212
-46
@@ -1,16 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipSwitch,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { Link } from '@/components/emcn/icons'
|
||||
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
|
||||
import { GeneratedPasswordInput } from '@/components/ui'
|
||||
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
|
||||
@@ -24,10 +32,26 @@ interface ShareModalProps {
|
||||
initialShare?: ShareRecord | null
|
||||
}
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'public', label: 'Anyone with link' },
|
||||
]
|
||||
type AccessMode = 'private' | ShareAuthType
|
||||
|
||||
const ACCESS_LABELS: Record<AccessMode, string> = {
|
||||
private: 'Private',
|
||||
public: 'Public',
|
||||
password: 'Password',
|
||||
email: 'Email',
|
||||
sso: 'SSO',
|
||||
}
|
||||
|
||||
function savedMode(share: ShareRecord | null): AccessMode {
|
||||
if (!share?.isActive) return 'private'
|
||||
return share.authType
|
||||
}
|
||||
|
||||
/** True when an entry is a valid email or an `@domain` pattern. */
|
||||
function isValidEmailEntry(value: string): boolean {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized.startsWith('@') || quickValidateEmail(normalized).isValid
|
||||
}
|
||||
|
||||
export function ShareModal({
|
||||
open,
|
||||
@@ -37,67 +61,209 @@ export function ShareModal({
|
||||
fileName,
|
||||
initialShare,
|
||||
}: ShareModalProps) {
|
||||
const { data: share } = useFileShare(workspaceId, fileId, { enabled: open })
|
||||
const { data: share, isFetched } = useFileShare(workspaceId, fileId, { enabled: open })
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const upsertShare = useUpsertFileShare()
|
||||
|
||||
const saved = share ?? initialShare ?? null
|
||||
const savedActive = saved?.isActive ?? false
|
||||
const savedAccessMode = savedMode(saved)
|
||||
|
||||
// Org access-control policy can disable enabling new public links (the route is the
|
||||
// source of truth; this just reflects it). Disabling an existing share stays allowed.
|
||||
const enableBlockedByPolicy = permissionConfig.disablePublicFileSharing && !savedActive
|
||||
// Reserve a token on open (one per mount — the modal remounts each open) so the
|
||||
// link can be shown and copied before the first save; it's persisted on save.
|
||||
// Only used once we've confirmed no share row exists yet, so a copied link
|
||||
// always matches what gets stored.
|
||||
const [pendingToken] = useState(() => generateShortId())
|
||||
const noExistingShare = isFetched && !share && !initialShare
|
||||
const shareUrl = saved?.url ?? (noExistingShare ? `${getBaseUrl()}/f/${pendingToken}` : null)
|
||||
|
||||
// `null` until the user toggles, so the switch always reflects the authoritative
|
||||
// saved state (which may resolve after mount via useFileShare) instead of a stale
|
||||
// initial snapshot — otherwise a Save could silently flip sharing the wrong way.
|
||||
const [draftActive, setDraftActive] = useState<boolean | null>(null)
|
||||
const effectiveActive = draftActive ?? savedActive
|
||||
const isDirty = draftActive !== null && draftActive !== savedActive
|
||||
// `null` until the user changes the selector, so the control always reflects the
|
||||
// authoritative saved state (which may resolve after mount via useFileShare).
|
||||
const [draftMode, setDraftMode] = useState<AccessMode | null>(null)
|
||||
const [draftPassword, setDraftPassword] = useState('')
|
||||
const [draftEmails, setDraftEmails] = useState<string[] | null>(null)
|
||||
const effectiveMode = draftMode ?? savedAccessMode
|
||||
const effectiveActive = effectiveMode !== 'private'
|
||||
const effectiveEmails = draftEmails ?? saved?.allowedEmails ?? []
|
||||
|
||||
const handleSave = () => {
|
||||
upsertShare.mutate(
|
||||
{ workspaceId, fileId, isActive: effectiveActive },
|
||||
{ onSuccess: () => onOpenChange(false) }
|
||||
)
|
||||
// Org access-control may restrict which auth modes are allowed (`null` = all).
|
||||
// The route is the source of truth; this just hides disallowed options.
|
||||
const allowedAuthTypes = permissionConfig.allowedFileShareAuthTypes
|
||||
const isAuthTypeAllowed = (mode: ShareAuthType) =>
|
||||
allowedAuthTypes === null || allowedAuthTypes.includes(mode)
|
||||
|
||||
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) || savedAccessMode === 'sso'
|
||||
const candidateAuthTypes: ShareAuthType[] = [
|
||||
'public',
|
||||
'password',
|
||||
'email',
|
||||
...(ssoEnabled ? (['sso'] as const) : []),
|
||||
]
|
||||
// Keep the saved mode visible even if newly disallowed, so the current state shows.
|
||||
const accessModes: AccessMode[] = [
|
||||
'private',
|
||||
...candidateAuthTypes.filter((mode) => isAuthTypeAllowed(mode) || mode === savedAccessMode),
|
||||
]
|
||||
|
||||
// The selected mode is blocked when org policy disables public sharing entirely
|
||||
// (enabling a new share) or when the chosen auth mode isn't allowed.
|
||||
const modeDisallowed = effectiveMode !== 'private' && !isAuthTypeAllowed(effectiveMode)
|
||||
const enableBlockedByPolicy =
|
||||
(permissionConfig.disablePublicFileSharing && !saved?.isActive) || modeDisallowed
|
||||
|
||||
// A password share needs a secret: either one already stored or a freshly typed one.
|
||||
const passwordMissing =
|
||||
effectiveMode === 'password' && !saved?.hasPassword && draftPassword.trim().length === 0
|
||||
// Email/SSO shares need at least one allowed email/domain.
|
||||
const emailsMissing =
|
||||
(effectiveMode === 'email' || effectiveMode === 'sso') && effectiveEmails.length === 0
|
||||
|
||||
const emailsDirty =
|
||||
draftEmails !== null &&
|
||||
JSON.stringify(draftEmails) !== JSON.stringify(saved?.allowedEmails ?? [])
|
||||
const isDirty =
|
||||
(draftMode !== null && draftMode !== savedAccessMode) ||
|
||||
(effectiveMode === 'password' && draftPassword.length > 0) ||
|
||||
((effectiveMode === 'email' || effectiveMode === 'sso') && emailsDirty)
|
||||
|
||||
const resetDraft = () => {
|
||||
setDraftMode(null)
|
||||
setDraftPassword('')
|
||||
setDraftEmails(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetDraft()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
// Persist the reserved token only when creating the row; existing shares keep
|
||||
// their own token (the server ignores this on conflict).
|
||||
const base = { workspaceId, fileId, token: saved ? undefined : pendingToken }
|
||||
const vars =
|
||||
effectiveMode === 'private'
|
||||
? { ...base, isActive: false as const }
|
||||
: effectiveMode === 'password'
|
||||
? {
|
||||
...base,
|
||||
isActive: true as const,
|
||||
authType: 'password' as const,
|
||||
password: draftPassword.trim() || undefined,
|
||||
}
|
||||
: effectiveMode === 'email' || effectiveMode === 'sso'
|
||||
? {
|
||||
...base,
|
||||
isActive: true as const,
|
||||
authType: effectiveMode,
|
||||
allowedEmails: effectiveEmails,
|
||||
}
|
||||
: { ...base, isActive: true as const, authType: 'public' as const }
|
||||
|
||||
upsertShare.mutate(vars, {
|
||||
onSuccess: () => {
|
||||
resetDraft()
|
||||
onOpenChange(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const addEmail = (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized || effectiveEmails.includes(normalized) || !isValidEmailEntry(normalized)) {
|
||||
return false
|
||||
}
|
||||
setDraftEmails([...effectiveEmails, normalized])
|
||||
return true
|
||||
}
|
||||
|
||||
const removeEmail = (_value: string, index: number) => {
|
||||
setDraftEmails(effectiveEmails.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const accessHint = (() => {
|
||||
if (modeDisallowed) return 'This sharing method is disabled by an administrator.'
|
||||
if (enableBlockedByPolicy)
|
||||
return 'Public sharing is disabled for this workspace by an administrator.'
|
||||
if (effectiveMode === 'private') return 'Only workspace members can access this file.'
|
||||
if (effectiveMode === 'password')
|
||||
return 'Anyone with the link and the password can view and download this file.'
|
||||
if (effectiveMode === 'email')
|
||||
return 'Only allowed emails can access this file after a one-time code.'
|
||||
if (effectiveMode === 'sso')
|
||||
return 'Only allowed emails signed in via SSO can access this file.'
|
||||
return isDirty
|
||||
? 'Save to make this file accessible to anyone with the link.'
|
||||
: 'Anyone with the link can view and download this file.'
|
||||
})()
|
||||
|
||||
const emailItems: TagItem[] = effectiveEmails.map((value) => ({ value, isValid: true }))
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} size='sm' srTitle={`Share ${fileName}`}>
|
||||
<ChipModalHeader icon={Link} onClose={() => onOpenChange(false)}>
|
||||
<ChipModal open={open} onOpenChange={handleClose} size='sm' srTitle={`Share ${fileName}`}>
|
||||
<ChipModalHeader icon={Link} onClose={handleClose}>
|
||||
Share file
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Access'
|
||||
hint={
|
||||
enableBlockedByPolicy
|
||||
? 'Public sharing is disabled for this workspace by an administrator.'
|
||||
: effectiveActive
|
||||
? isDirty
|
||||
? 'Save to make this file accessible to anyone with the link.'
|
||||
: 'Anyone with the link can view and download this file.'
|
||||
: 'Only workspace members can access this file.'
|
||||
}
|
||||
>
|
||||
<ChipSwitch
|
||||
value={effectiveActive ? 'public' : 'private'}
|
||||
onChange={(value) => setDraftActive(value === 'public')}
|
||||
options={VISIBILITY_OPTIONS}
|
||||
<ChipModalField type='custom' title='Access' hint={accessHint}>
|
||||
<ButtonGroup
|
||||
value={effectiveMode}
|
||||
onValueChange={(value) => setDraftMode(value as AccessMode)}
|
||||
aria-label='File access'
|
||||
/>
|
||||
>
|
||||
{accessModes.map((mode) => (
|
||||
<ButtonGroupItem key={mode} value={mode}>
|
||||
{ACCESS_LABELS[mode]}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</ChipModalField>
|
||||
{saved?.isActive ? (
|
||||
<ChipModalField type='copy' title='Link' value={saved.url} copyLabel='Copy link' />
|
||||
{effectiveMode === 'password' ? (
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Password'
|
||||
hint={
|
||||
saved?.hasPassword
|
||||
? 'Leave blank to keep the current password.'
|
||||
: 'Anyone with the link must enter this password.'
|
||||
}
|
||||
>
|
||||
<GeneratedPasswordInput
|
||||
value={draftPassword}
|
||||
onChange={setDraftPassword}
|
||||
placeholder={saved?.hasPassword ? '••••••••' : 'Enter a password'}
|
||||
/>
|
||||
</ChipModalField>
|
||||
) : null}
|
||||
{effectiveMode === 'email' || effectiveMode === 'sso' ? (
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
title='Allowed emails'
|
||||
hint='Add specific emails or whole domains (@example.com).'
|
||||
>
|
||||
<TagInput
|
||||
items={emailItems}
|
||||
onAdd={addEmail}
|
||||
onRemove={removeEmail}
|
||||
placeholder='Enter emails or domains'
|
||||
placeholderWithTags='Add email'
|
||||
/>
|
||||
</ChipModalField>
|
||||
) : null}
|
||||
{effectiveMode !== 'private' && shareUrl ? (
|
||||
<ChipModalField type='copy' title='Link' value={shareUrl} copyLabel='Copy link' />
|
||||
) : null}
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onCancel={handleClose}
|
||||
primaryAction={{
|
||||
label: upsertShare.isPending ? 'Saving...' : 'Save',
|
||||
onClick: handleSave,
|
||||
disabled: !isDirty || upsertShare.isPending || (effectiveActive && enableBlockedByPolicy),
|
||||
disabled:
|
||||
!isDirty ||
|
||||
upsertShare.isPending ||
|
||||
passwordMissing ||
|
||||
emailsMissing ||
|
||||
(effectiveActive && enableBlockedByPolicy),
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
+5
-85
@@ -3,9 +3,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { AlertTriangle, Check, Clipboard, Eye, EyeOff, RefreshCw } from 'lucide-react'
|
||||
import { AlertTriangle, Check } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
ChipConfirmModal,
|
||||
@@ -19,8 +18,8 @@ import {
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { GeneratedPasswordInput } from '@/components/ui'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { generatePassword } from '@/lib/core/security/encryption'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
@@ -611,9 +610,7 @@ function AuthSelector({
|
||||
hasExistingPassword = false,
|
||||
error,
|
||||
}: AuthSelectorProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
const [invalidEmailItems, setInvalidEmailItems] = useState<TagItem[]>([])
|
||||
|
||||
const emailsRef = useRef(emails)
|
||||
@@ -623,22 +620,6 @@ function AuthSelector({
|
||||
emailsRef.current = emails
|
||||
}, [emails])
|
||||
|
||||
useEffect(() => {
|
||||
if (!copySuccess) return
|
||||
const timer = setTimeout(() => setCopySuccess(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [copySuccess])
|
||||
|
||||
const handleGeneratePassword = () => {
|
||||
const newPassword = generatePassword(24)
|
||||
onPasswordChange(newPassword)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopySuccess(true)
|
||||
}
|
||||
|
||||
const addEmail = (email: string): boolean => {
|
||||
if (!email.trim()) return false
|
||||
|
||||
@@ -718,73 +699,12 @@ function AuthSelector({
|
||||
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
|
||||
Password
|
||||
</Label>
|
||||
<ChipInput
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={getPasswordPlaceholder(hasExistingPassword)}
|
||||
<GeneratedPasswordInput
|
||||
value={password}
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
onChange={onPasswordChange}
|
||||
disabled={disabled}
|
||||
placeholder={getPasswordPlaceholder(hasExistingPassword)}
|
||||
required={!hasExistingPassword}
|
||||
autoComplete='new-password'
|
||||
endAdornment={
|
||||
<div className='flex items-center'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={handleGeneratePassword}
|
||||
disabled={disabled}
|
||||
aria-label='Generate password'
|
||||
className='!p-1.5'
|
||||
>
|
||||
<RefreshCw className='size-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Generate</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => copyToClipboard(password)}
|
||||
disabled={!password || disabled}
|
||||
aria-label='Copy password'
|
||||
className='!p-1.5'
|
||||
>
|
||||
{copySuccess ? (
|
||||
<Check className='size-3' />
|
||||
) : (
|
||||
<Clipboard className='size-3' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{copySuccess ? 'Copied' : 'Copy'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className='!p-1.5'
|
||||
>
|
||||
{showPassword ? <EyeOff className='size-3' /> : <Eye className='size-3' />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{showPassword ? 'Hide' : 'Show'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
|
||||
{getPasswordHelperText(hasExistingPassword)}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Clipboard, Eye, EyeOff, RefreshCw } from 'lucide-react'
|
||||
import { Button, ChipInput, Tooltip } from '@/components/emcn'
|
||||
import { generatePassword } from '@/lib/core/security/encryption'
|
||||
|
||||
interface GeneratedPasswordInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
/** Show the Generate (random password) action. Off for consumer-facing entry forms. */
|
||||
showGenerate?: boolean
|
||||
required?: boolean
|
||||
autoComplete?: string
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Password field with reveal / copy / (optional) generate adornments, used by the
|
||||
* deploy-as-chat access controls and the file-share modal. Owns its show/copy UI
|
||||
* state; the caller owns the value.
|
||||
*/
|
||||
export function GeneratedPasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
showGenerate = true,
|
||||
required = false,
|
||||
autoComplete = 'new-password',
|
||||
error = false,
|
||||
}: GeneratedPasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!copySuccess) return
|
||||
const timer = setTimeout(() => setCopySuccess(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [copySuccess])
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(value)
|
||||
setCopySuccess(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipInput
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
error={error}
|
||||
endAdornment={
|
||||
<div className='flex items-center'>
|
||||
{showGenerate ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => onChange(generatePassword(24))}
|
||||
disabled={disabled}
|
||||
aria-label='Generate password'
|
||||
className='!p-1.5'
|
||||
>
|
||||
<RefreshCw className='size-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Generate</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : null}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={copyToClipboard}
|
||||
disabled={!value || disabled}
|
||||
aria-label='Copy password'
|
||||
className='!p-1.5'
|
||||
>
|
||||
{copySuccess ? <Check className='size-3' /> : <Clipboard className='size-3' />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{copySuccess ? 'Copied' : 'Copy'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className='!p-1.5'
|
||||
>
|
||||
{showPassword ? <EyeOff className='size-3' /> : <Eye className='size-3' />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{showPassword ? 'Hide' : 'Show'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Button, buttonVariants } from './button'
|
||||
export { GeneratedPasswordInput } from './generated-password-input'
|
||||
export { Progress } from './progress'
|
||||
export { SearchHighlight } from './search-highlight'
|
||||
export {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import { ArrowLeft } from '@/components/emcn/icons'
|
||||
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
|
||||
@@ -69,6 +70,15 @@ import type { ProviderName } from '@/stores/providers'
|
||||
|
||||
const logger = createLogger('AccessControl')
|
||||
|
||||
/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */
|
||||
const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
|
||||
{ value: 'public', label: 'Anyone with link' },
|
||||
{ value: 'password', label: 'Password' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'sso', label: 'SSO' },
|
||||
]
|
||||
const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value)
|
||||
|
||||
interface OrganizationMemberOption {
|
||||
userId: string
|
||||
user: {
|
||||
@@ -1070,6 +1080,36 @@ export function AccessControl() {
|
||||
[editingConfig, allProviderIds]
|
||||
)
|
||||
|
||||
const isFileShareAuthAllowed = useCallback(
|
||||
(authType: ShareAuthType) => {
|
||||
if (!editingConfig) return true
|
||||
return (
|
||||
editingConfig.allowedFileShareAuthTypes === null ||
|
||||
editingConfig.allowedFileShareAuthTypes.includes(authType)
|
||||
)
|
||||
},
|
||||
[editingConfig]
|
||||
)
|
||||
|
||||
const toggleFileShareAuthType = useCallback(
|
||||
(authType: ShareAuthType) => {
|
||||
if (!editingConfig) return
|
||||
const current = editingConfig.allowedFileShareAuthTypes
|
||||
const next =
|
||||
current === null
|
||||
? ALL_FILE_SHARE_AUTH_TYPES.filter((t) => t !== authType)
|
||||
: current.includes(authType)
|
||||
? current.filter((t) => t !== authType)
|
||||
: [...current, authType]
|
||||
// A full list collapses back to `null` ("all allowed").
|
||||
setEditingConfig({
|
||||
...editingConfig,
|
||||
allowedFileShareAuthTypes: next.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : next,
|
||||
})
|
||||
},
|
||||
[editingConfig]
|
||||
)
|
||||
|
||||
const isIntegrationAllowed = useCallback(
|
||||
(blockType: string) => {
|
||||
if (!editingConfig) return true
|
||||
@@ -1603,6 +1643,30 @@ export function AccessControl() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-8 flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-xs uppercase tracking-wide'>
|
||||
File Sharing Methods
|
||||
</span>
|
||||
<p className='text-[var(--text-secondary)] text-xs'>
|
||||
Auth modes that public file-share links may use.
|
||||
</p>
|
||||
<div className='flex max-w-md flex-col gap-0.5 pt-1'>
|
||||
{FILE_SHARE_AUTH_TYPE_OPTIONS.map(({ value, label }) => (
|
||||
<label
|
||||
key={value}
|
||||
htmlFor={`fsauth-${value}`}
|
||||
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<Checkbox
|
||||
id={`fsauth-${value}`}
|
||||
checked={isFileShareAuthAllowed(value)}
|
||||
onCheckedChange={() => toggleFileShareAuthType(value)}
|
||||
/>
|
||||
<span className='font-normal text-sm'>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChipModalBody>
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
disableInvitations: false,
|
||||
disablePublicApi: false,
|
||||
disablePublicFileSharing: false,
|
||||
allowedFileShareAuthTypes: null,
|
||||
hideDeployApi: false,
|
||||
hideDeployMcp: false,
|
||||
hideDeployA2a: false,
|
||||
@@ -149,10 +150,12 @@ import {
|
||||
McpToolsNotAllowedError,
|
||||
ModelNotAllowedError,
|
||||
ProviderNotAllowedError,
|
||||
PublicFileSharingNotAllowedError,
|
||||
SkillsNotAllowedError,
|
||||
validateBlockType,
|
||||
validateMcpToolsAllowed,
|
||||
validateModelProvider,
|
||||
validatePublicFileSharing,
|
||||
} from './permission-check'
|
||||
|
||||
/** Default an org-backed, enterprise-entitled workspace so resolution reaches the group queries. */
|
||||
@@ -532,6 +535,46 @@ describe('validateMcpToolsAllowed', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePublicFileSharing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExplicitGroup.value = []
|
||||
mockAllWorkspacesGroup.value = []
|
||||
mockDefaultGroup.value = []
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
setEnterpriseOrgWorkspace()
|
||||
})
|
||||
|
||||
it('throws when public file sharing is fully disabled', async () => {
|
||||
mockExplicitGroup.value = [{ config: { disablePublicFileSharing: true } }]
|
||||
await expect(
|
||||
validatePublicFileSharing('user-123', 'workspace-1', 'password')
|
||||
).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError)
|
||||
})
|
||||
|
||||
it('throws when the auth type is not in the allow-list', async () => {
|
||||
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]
|
||||
await expect(
|
||||
validatePublicFileSharing('user-123', 'workspace-1', 'public')
|
||||
).rejects.toBeInstanceOf(PublicFileSharingNotAllowedError)
|
||||
})
|
||||
|
||||
it('allows an auth type that is in the allow-list', async () => {
|
||||
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: ['password', 'sso'] } }]
|
||||
await validatePublicFileSharing('user-123', 'workspace-1', 'password')
|
||||
})
|
||||
|
||||
it('allows any auth type when the allow-list is null', async () => {
|
||||
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: null } }]
|
||||
await validatePublicFileSharing('user-123', 'workspace-1', 'email')
|
||||
})
|
||||
|
||||
it('no-ops when no auth type is provided (master switch only)', async () => {
|
||||
mockExplicitGroup.value = [{ config: { allowedFileShareAuthTypes: ['password'] } }]
|
||||
await validatePublicFileSharing('user-123', 'workspace-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertPermissionsAllowed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import {
|
||||
getAllowedIntegrationsFromEnv,
|
||||
@@ -293,15 +294,33 @@ export async function getUserPermissionConfig(
|
||||
|
||||
/**
|
||||
* Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission
|
||||
* group for the workspace disables public file sharing. No-op when access control
|
||||
* doesn't apply (non-enterprise / disabled), so non-governed orgs are unaffected.
|
||||
* group for the workspace disables public file sharing, or — when `authType` is
|
||||
* given — if that auth mode isn't in the group's `allowedFileShareAuthTypes`
|
||||
* allow-list (`null` allows all). No-op when access control doesn't apply
|
||||
* (non-enterprise / disabled), so non-governed orgs are unaffected.
|
||||
*/
|
||||
export async function validatePublicFileSharing(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
authType?: ShareAuthType
|
||||
): Promise<void> {
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
if (config?.disablePublicFileSharing) {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
if (config.disablePublicFileSharing) {
|
||||
throw new PublicFileSharingNotAllowedError()
|
||||
}
|
||||
if (
|
||||
authType &&
|
||||
config.allowedFileShareAuthTypes !== null &&
|
||||
!config.allowedFileShareAuthTypes.includes(authType)
|
||||
) {
|
||||
logger.warn('File share auth type blocked by permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
authType,
|
||||
})
|
||||
throw new PublicFileSharingNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '@/components/emcn'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type AuthenticatePublicFileResponse,
|
||||
authenticatePublicFileContract,
|
||||
getFileShareContract,
|
||||
requestPublicFileOtpContract,
|
||||
type ShareRecord,
|
||||
type UpsertFileShareBody,
|
||||
upsertFileShareContract,
|
||||
type VerifyPublicFileOtpResponse,
|
||||
verifyPublicFileOtpContract,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
|
||||
|
||||
@@ -48,34 +53,57 @@ interface UpsertFileShareVariables extends UpsertFileShareBody {
|
||||
export function useUpsertFileShare() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ workspaceId, fileId, isActive }: UpsertFileShareVariables) =>
|
||||
mutationFn: ({ workspaceId, fileId, ...body }: UpsertFileShareVariables) =>
|
||||
requestJson(upsertFileShareContract, {
|
||||
params: { id: workspaceId, fileId },
|
||||
body: { isActive },
|
||||
body,
|
||||
}),
|
||||
onSuccess: (data, { workspaceId, fileId, isActive }) => {
|
||||
onSuccess: (data, { workspaceId, fileId }) => {
|
||||
queryClient.setQueryData(shareKeys.detail(workspaceId, fileId), data.share)
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
|
||||
if (!isActive) {
|
||||
toast.success('Sharing turned off')
|
||||
return
|
||||
}
|
||||
const { url } = data.share
|
||||
toast.success('Public link enabled', {
|
||||
description: url,
|
||||
action: {
|
||||
label: 'Copy link',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(url).then(
|
||||
() => toast.success('Link copied'),
|
||||
() => toast.error('Failed to copy link')
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges a share password for a `file_auth_{shareId}` cookie on the public
|
||||
* file page. On success the page should `router.refresh()` to re-render the
|
||||
* now-authorized viewer.
|
||||
*/
|
||||
export function usePublicFileAuth(token: string) {
|
||||
return useMutation<AuthenticatePublicFileResponse, Error, { password: string }>({
|
||||
mutationFn: ({ password }) =>
|
||||
requestJson(authenticatePublicFileContract, {
|
||||
params: { token },
|
||||
body: { password },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Requests a verification code for an email-gated share (initial send + resend). */
|
||||
export function usePublicFileOtpRequest(token: string) {
|
||||
return useMutation<{ message: string }, Error, { email: string }>({
|
||||
mutationFn: ({ email }) =>
|
||||
requestJson(requestPublicFileOtpContract, {
|
||||
params: { token },
|
||||
body: { email },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the OTP for an email-gated share. On success the server sets the
|
||||
* `file_auth_{shareId}` cookie; the page should then `router.refresh()`.
|
||||
*/
|
||||
export function usePublicFileOtpVerify(token: string) {
|
||||
return useMutation<VerifyPublicFileOtpResponse, Error, { email: string; otp: string }>({
|
||||
mutationFn: ({ email, otp }) =>
|
||||
requestJson(verifyPublicFileOtpContract, {
|
||||
params: { token },
|
||||
body: { email, otp },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
|
||||
import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import { permissionGroupConfigSchema } from '@/lib/permission-groups/types'
|
||||
|
||||
@@ -22,6 +23,7 @@ export const permissionGroupFullConfigSchema = z.object({
|
||||
disableInvitations: z.boolean(),
|
||||
disablePublicApi: z.boolean(),
|
||||
disablePublicFileSharing: z.boolean(),
|
||||
allowedFileShareAuthTypes: z.array(shareAuthTypeSchema).nullable(),
|
||||
hideDeployApi: z.boolean(),
|
||||
hideDeployMcp: z.boolean(),
|
||||
hideDeployA2a: z.boolean(),
|
||||
|
||||
@@ -4,9 +4,19 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
|
||||
export const shareResourceTypeSchema = z.enum(['file', 'folder'])
|
||||
|
||||
/** How a public share is gated. */
|
||||
export const shareAuthTypeSchema = z.enum(['public', 'password', 'email', 'sso'])
|
||||
|
||||
export type ShareAuthType = z.output<typeof shareAuthTypeSchema>
|
||||
|
||||
/** An allowed email address or `@domain` pattern for email/SSO shares. */
|
||||
const allowedEmailSchema = z.string().min(1).max(320)
|
||||
|
||||
/**
|
||||
* Public-safe representation of a `public_share` row. Never carries the
|
||||
* underlying storage key.
|
||||
* underlying storage key or the (encrypted) password — `hasPassword` is the
|
||||
* only password signal exposed to clients. `allowedEmails` is the allow-list for
|
||||
* email/SSO shares (visible only to workspace members via the authed share route).
|
||||
*/
|
||||
export const shareRecordSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -15,6 +25,9 @@ export const shareRecordSchema = z.object({
|
||||
isActive: z.boolean(),
|
||||
resourceType: shareResourceTypeSchema,
|
||||
resourceId: z.string(),
|
||||
authType: shareAuthTypeSchema,
|
||||
hasPassword: z.boolean(),
|
||||
allowedEmails: z.array(allowedEmailSchema),
|
||||
})
|
||||
|
||||
export type ShareRecord = z.output<typeof shareRecordSchema>
|
||||
@@ -26,6 +39,21 @@ const fileShareParamsSchema = z.object({
|
||||
|
||||
export const upsertFileShareBodySchema = z.object({
|
||||
isActive: z.boolean(),
|
||||
authType: shareAuthTypeSchema.optional(),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password cannot be empty')
|
||||
.max(1024, 'Password is too long')
|
||||
.optional(),
|
||||
allowedEmails: z.array(allowedEmailSchema).max(200, 'Too many allowed emails').optional(),
|
||||
// Client-reserved token shown as the link before saving; persisted on first
|
||||
// enable so a copied link resolves. Ignored once the share row already exists.
|
||||
token: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9_-]+$/, 'Invalid token')
|
||||
.min(16, 'Token is too short')
|
||||
.max(64, 'Token is too long')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type UpsertFileShareBody = z.input<typeof upsertFileShareBodySchema>
|
||||
@@ -97,3 +125,100 @@ export const getPublicFileContentContract = defineRouteContract({
|
||||
mode: 'binary',
|
||||
},
|
||||
})
|
||||
|
||||
const authenticatePublicFileBodySchema = z.object({
|
||||
password: z.string().min(1, 'Password is required').max(1024, 'Password is too long'),
|
||||
})
|
||||
|
||||
export type AuthenticatePublicFileBody = z.input<typeof authenticatePublicFileBodySchema>
|
||||
|
||||
const authenticatePublicFileResponseSchema = z.object({
|
||||
authType: shareAuthTypeSchema,
|
||||
})
|
||||
|
||||
export type AuthenticatePublicFileResponse = z.output<typeof authenticatePublicFileResponseSchema>
|
||||
|
||||
/**
|
||||
* Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited;
|
||||
* returns 401 (`Invalid password`) on mismatch and 429 when throttled.
|
||||
*/
|
||||
export const authenticatePublicFileContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/files/public/[token]',
|
||||
params: publicFileTokenParamsSchema,
|
||||
body: authenticatePublicFileBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: authenticatePublicFileResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
const requestPublicFileOtpBodySchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
})
|
||||
|
||||
export type RequestPublicFileOtpBody = z.input<typeof requestPublicFileOtpBodySchema>
|
||||
|
||||
const requestPublicFileOtpResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
})
|
||||
|
||||
/** Sends a 6-digit verification code to an allow-listed email for an email-gated share. */
|
||||
export const requestPublicFileOtpContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/files/public/[token]/otp',
|
||||
params: publicFileTokenParamsSchema,
|
||||
body: requestPublicFileOtpBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: requestPublicFileOtpResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
const verifyPublicFileOtpBodySchema = requestPublicFileOtpBodySchema.extend({
|
||||
otp: z.string().length(6, 'Verification code must be 6 digits'),
|
||||
})
|
||||
|
||||
export type VerifyPublicFileOtpBody = z.input<typeof verifyPublicFileOtpBodySchema>
|
||||
|
||||
const verifyPublicFileOtpResponseSchema = z.object({
|
||||
authType: shareAuthTypeSchema,
|
||||
})
|
||||
|
||||
export type VerifyPublicFileOtpResponse = z.output<typeof verifyPublicFileOtpResponseSchema>
|
||||
|
||||
/** Verifies the OTP and, on success, sets the `file_auth_{shareId}` cookie. */
|
||||
export const verifyPublicFileOtpContract = defineRouteContract({
|
||||
method: 'PUT',
|
||||
path: '/api/files/public/[token]/otp',
|
||||
params: publicFileTokenParamsSchema,
|
||||
body: verifyPublicFileOtpBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: verifyPublicFileOtpResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
const publicFileSSOBodySchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
})
|
||||
|
||||
export type PublicFileSSOBody = z.input<typeof publicFileSSOBodySchema>
|
||||
|
||||
const publicFileSSOResponseSchema = z.object({
|
||||
eligible: z.boolean(),
|
||||
})
|
||||
|
||||
export type PublicFileSSOResponse = z.output<typeof publicFileSSOResponseSchema>
|
||||
|
||||
/** Reports whether an email is on the allow-list for an SSO-gated share. */
|
||||
export const publicFileSSOContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/files/public/[token]/sso',
|
||||
params: publicFileTokenParamsSchema,
|
||||
body: publicFileSSOBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: publicFileSSOResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import {
|
||||
type DeploymentAuthKind,
|
||||
deploymentAuthCookieName,
|
||||
isEmailAllowed,
|
||||
validateAuthToken,
|
||||
} from '@/lib/core/security/deployment'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getClientIp } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('DeploymentAuth')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
/**
|
||||
* Throttles unauthenticated password guesses per client IP against a single
|
||||
* deployment, mirroring the OTP/SSO IP limits.
|
||||
*/
|
||||
const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 10,
|
||||
refillRate: 10,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* A password/email-gated resource (a deployed chat or a public file share). Only
|
||||
* the fields the auth check needs — the `password` is the encrypted secret.
|
||||
*/
|
||||
export interface DeploymentAuthResource {
|
||||
id: string
|
||||
authType: string | null
|
||||
password?: string | null
|
||||
allowedEmails?: unknown
|
||||
}
|
||||
|
||||
interface DeploymentAuthBody {
|
||||
password?: string
|
||||
email?: string
|
||||
input?: unknown
|
||||
}
|
||||
|
||||
export interface DeploymentAuthResult {
|
||||
authorized: boolean
|
||||
error?: string
|
||||
status?: number
|
||||
retryAfterMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared password/email/SSO gate for deployed resources. The `cookiePrefix`
|
||||
* selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit
|
||||
* namespace so chat deployments and public file shares share one code path. Both
|
||||
* support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`.
|
||||
*/
|
||||
export async function validateDeploymentAuth(
|
||||
requestId: string,
|
||||
resource: DeploymentAuthResource,
|
||||
request: NextRequest,
|
||||
parsedBody: DeploymentAuthBody | null | undefined,
|
||||
cookiePrefix: DeploymentAuthKind
|
||||
): Promise<DeploymentAuthResult> {
|
||||
const authType = resource.authType || 'public'
|
||||
|
||||
if (authType === 'public') {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
if (authType !== 'sso') {
|
||||
const authCookie = request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id))
|
||||
|
||||
if (
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, resource.id, authType, resource.password)
|
||||
) {
|
||||
return { authorized: true }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'password') {
|
||||
if (request.method === 'GET') {
|
||||
return { authorized: false, error: 'auth_required_password' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!parsedBody) {
|
||||
return { authorized: false, error: 'Password is required' }
|
||||
}
|
||||
|
||||
const { password, input } = parsedBody
|
||||
|
||||
if (input && !password) {
|
||||
return { authorized: false, error: 'auth_required_password' }
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return { authorized: false, error: 'Password is required' }
|
||||
}
|
||||
|
||||
if (!resource.password) {
|
||||
logger.error(`[${requestId}] No password set for password-protected ${resource.id}`)
|
||||
return { authorized: false, error: 'Authentication configuration error' }
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`${cookiePrefix}-password:ip:${resource.id}:${ip}`,
|
||||
PASSWORD_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] Password attempt IP rate limit exceeded for ${resource.id} from ${ip}`
|
||||
)
|
||||
return {
|
||||
authorized: false,
|
||||
error: 'Too many attempts. Please try again later.',
|
||||
status: 429,
|
||||
retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs,
|
||||
}
|
||||
}
|
||||
|
||||
const { decrypted } = await decryptSecret(resource.password)
|
||||
if (!safeCompare(password, decrypted)) {
|
||||
return { authorized: false, error: 'Invalid password' }
|
||||
}
|
||||
|
||||
return { authorized: true }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating password:`, error)
|
||||
return { authorized: false, error: 'Authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'email') {
|
||||
if (request.method === 'GET') {
|
||||
return { authorized: false, error: 'auth_required_email' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!parsedBody) {
|
||||
return { authorized: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
const { email, input } = parsedBody
|
||||
|
||||
if (input && !email) {
|
||||
return { authorized: false, error: 'auth_required_email' }
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return { authorized: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
const allowedEmails = (resource.allowedEmails as string[]) || []
|
||||
|
||||
if (isEmailAllowed(email, allowedEmails)) {
|
||||
return { authorized: false, error: 'otp_required' }
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Email not authorized' }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating email:`, error)
|
||||
return { authorized: false, error: 'Authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'sso') {
|
||||
try {
|
||||
if (request.method !== 'GET' && !parsedBody) {
|
||||
return { authorized: false, error: 'SSO authentication is required' }
|
||||
}
|
||||
|
||||
const { getSession } = await import('@/lib/auth')
|
||||
const session = await getSession()
|
||||
|
||||
if (!session || !session.user) {
|
||||
return { authorized: false, error: 'auth_required_sso' }
|
||||
}
|
||||
|
||||
const userEmail = session.user.email
|
||||
if (!userEmail) {
|
||||
return { authorized: false, error: 'SSO session does not contain email' }
|
||||
}
|
||||
|
||||
const allowedEmails = (resource.allowedEmails as string[]) || []
|
||||
|
||||
if (isEmailAllowed(userEmail, allowedEmails)) {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Your email is not authorized to access this resource' }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating SSO:`, error)
|
||||
return { authorized: false, error: 'SSO authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Unsupported authentication type' }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
|
||||
describe('isEmailAllowed', () => {
|
||||
it('matches an exact email regardless of casing on either side', () => {
|
||||
expect(isEmailAllowed('user@acme.com', ['user@acme.com'])).toBe(true)
|
||||
expect(isEmailAllowed('User@Acme.com', ['user@acme.com'])).toBe(true)
|
||||
expect(isEmailAllowed('user@acme.com', ['USER@ACME.COM'])).toBe(true)
|
||||
expect(isEmailAllowed(' User@Acme.com ', ['user@acme.com'])).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a domain pattern regardless of casing (covers IdP/session emails)', () => {
|
||||
expect(isEmailAllowed('User@Acme.com', ['@acme.com'])).toBe(true)
|
||||
expect(isEmailAllowed('user@acme.com', ['@Acme.com'])).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects emails not on the allow-list', () => {
|
||||
expect(isEmailAllowed('user@evil.com', ['user@acme.com', '@acme.com'])).toBe(false)
|
||||
expect(isEmailAllowed('user@acme.com', [])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@ function generateAuthToken(
|
||||
export function validateAuthToken(
|
||||
token: string,
|
||||
deploymentId: string,
|
||||
authType: string,
|
||||
encryptedPassword?: string | null
|
||||
): boolean {
|
||||
try {
|
||||
@@ -55,10 +56,15 @@ export function validateAuthToken(
|
||||
|
||||
const parts = payload.split(':')
|
||||
if (parts.length < 4) return false
|
||||
const [storedId, _type, timestamp, storedPwSlot] = parts
|
||||
const [storedId, storedType, timestamp, storedPwSlot] = parts
|
||||
|
||||
if (storedId !== deploymentId) return false
|
||||
|
||||
// Bind the cookie to the auth type so a token minted under one mode (e.g. a
|
||||
// `public` share, which has an empty password slot) can't satisfy another
|
||||
// mode (e.g. `email` OTP) after the share's auth type is changed.
|
||||
if (storedType !== authType) return false
|
||||
|
||||
const expectedPwSlot = passwordSlot(encryptedPassword)
|
||||
if (storedPwSlot !== expectedPwSlot) return false
|
||||
|
||||
@@ -72,19 +78,27 @@ export function validateAuthToken(
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind of deployed resource an auth cookie/token belongs to. */
|
||||
export type DeploymentAuthKind = 'chat' | 'file'
|
||||
|
||||
/** Canonical auth cookie name for a deployed resource (`{kind}_auth_{id}`). */
|
||||
export function deploymentAuthCookieName(cookiePrefix: DeploymentAuthKind, id: string): string {
|
||||
return `${cookiePrefix}_auth_${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an authentication cookie for a deployment
|
||||
*/
|
||||
export function setDeploymentAuthCookie(
|
||||
response: NextResponse,
|
||||
cookiePrefix: 'chat',
|
||||
cookiePrefix: DeploymentAuthKind,
|
||||
deploymentId: string,
|
||||
authType: string,
|
||||
encryptedPassword?: string | null
|
||||
): void {
|
||||
const token = generateAuthToken(deploymentId, authType, encryptedPassword)
|
||||
response.cookies.set({
|
||||
name: `${cookiePrefix}_auth_${deploymentId}`,
|
||||
name: deploymentAuthCookieName(cookiePrefix, deploymentId),
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
@@ -95,17 +109,22 @@ export function setDeploymentAuthCookie(
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email matches the allowed emails list (exact match or domain match)
|
||||
* Checks if an email matches the allowed emails list (exact match or domain
|
||||
* match). Case-insensitive — email addresses are compared lowercased on both
|
||||
* sides, so callers don't need to normalize before calling.
|
||||
*/
|
||||
export function isEmailAllowed(email: string, allowedEmails: string[]): boolean {
|
||||
if (allowedEmails.includes(email)) {
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const normalizedAllowed = allowedEmails.map((allowed) => allowed.trim().toLowerCase())
|
||||
|
||||
if (normalizedAllowed.includes(normalizedEmail)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const atIndex = email.indexOf('@')
|
||||
const atIndex = normalizedEmail.indexOf('@')
|
||||
if (atIndex > 0) {
|
||||
const domain = email.substring(atIndex + 1)
|
||||
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
|
||||
const domain = normalizedEmail.substring(atIndex + 1)
|
||||
if (domain && normalizedAllowed.some((allowed) => allowed === `@${domain}`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { getStorageMethod } from '@/lib/core/storage'
|
||||
|
||||
export type DeploymentKind = 'chat'
|
||||
export type DeploymentKind = 'chat' | 'file'
|
||||
|
||||
/**
|
||||
* Shared OTP configuration for deployment (chat) email-auth gates.
|
||||
* Shared OTP configuration for deployment email-auth gates (chat + public file shares).
|
||||
*/
|
||||
export const OTP_EXPIRY_SECONDS = 15 * 60
|
||||
export const OTP_EXPIRY_MS = OTP_EXPIRY_SECONDS * 1000
|
||||
@@ -38,6 +38,10 @@ const OTP_KEYS = {
|
||||
redisKey: (email: string, deploymentId: string) => `otp:${email}:${deploymentId}`,
|
||||
dbIdentifier: (email: string, deploymentId: string) => `chat-otp:${deploymentId}:${email}`,
|
||||
},
|
||||
file: {
|
||||
redisKey: (email: string, deploymentId: string) => `otp:file:${email}:${deploymentId}`,
|
||||
dbIdentifier: (email: string, deploymentId: string) => `file-otp:${deploymentId}:${email}`,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
DeploymentKind,
|
||||
{
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
|
||||
|
||||
/** Auth modes a public file share can use; admins may restrict the allowed subset. */
|
||||
export const FILE_SHARE_AUTH_TYPES = ['public', 'password', 'email', 'sso'] as const
|
||||
|
||||
export const PERMISSION_GROUP_CONSTRAINTS = {
|
||||
organizationName: 'permission_group_organization_name_unique',
|
||||
@@ -32,6 +36,7 @@ export const permissionGroupConfigSchema = z.object({
|
||||
disableInvitations: z.boolean().optional(),
|
||||
disablePublicApi: z.boolean().optional(),
|
||||
disablePublicFileSharing: z.boolean().optional(),
|
||||
allowedFileShareAuthTypes: z.array(z.enum(FILE_SHARE_AUTH_TYPES)).nullable().optional(),
|
||||
hideDeployApi: z.boolean().optional(),
|
||||
hideDeployMcp: z.boolean().optional(),
|
||||
hideDeployA2a: z.boolean().optional(),
|
||||
@@ -62,6 +67,8 @@ export interface PermissionGroupConfig {
|
||||
disableInvitations: boolean
|
||||
disablePublicApi: boolean
|
||||
disablePublicFileSharing: boolean
|
||||
/** Allowed public-file-share auth modes; `null` means all are allowed. */
|
||||
allowedFileShareAuthTypes: ShareAuthType[] | null
|
||||
hideDeployApi: boolean
|
||||
hideDeployMcp: boolean
|
||||
hideDeployA2a: boolean
|
||||
@@ -88,6 +95,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
|
||||
disableInvitations: false,
|
||||
disablePublicApi: false,
|
||||
disablePublicFileSharing: false,
|
||||
allowedFileShareAuthTypes: null,
|
||||
hideDeployApi: false,
|
||||
hideDeployMcp: false,
|
||||
hideDeployA2a: false,
|
||||
@@ -125,6 +133,11 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
|
||||
disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false,
|
||||
disablePublicFileSharing:
|
||||
typeof c.disablePublicFileSharing === 'boolean' ? c.disablePublicFileSharing : false,
|
||||
allowedFileShareAuthTypes: Array.isArray(c.allowedFileShareAuthTypes)
|
||||
? c.allowedFileShareAuthTypes.filter((t): t is ShareAuthType =>
|
||||
(FILE_SHARE_AUTH_TYPES as readonly string[]).includes(t as string)
|
||||
)
|
||||
: null,
|
||||
hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false,
|
||||
hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false,
|
||||
hideDeployA2a: typeof c.hideDeployA2a === 'boolean' ? c.hideDeployA2a : false,
|
||||
|
||||
@@ -4,11 +4,24 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateId, generateShortId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { z } from 'zod'
|
||||
import type { ShareRecord, shareResourceTypeSchema } from '@/lib/api/contracts/public-shares'
|
||||
import type {
|
||||
ShareAuthType,
|
||||
ShareRecord,
|
||||
shareResourceTypeSchema,
|
||||
} from '@/lib/api/contracts/public-shares'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('PublicShareManager')
|
||||
|
||||
/** Thrown when share auth config is invalid (e.g. enabling a password share with no password). Maps to a 400. */
|
||||
export class ShareValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ShareValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
type ShareResourceType = z.infer<typeof shareResourceTypeSchema>
|
||||
|
||||
type PublicShareRow = typeof publicShare.$inferSelect
|
||||
@@ -26,6 +39,9 @@ function mapShareRecord(row: PublicShareRow): ShareRecord {
|
||||
isActive: row.isActive,
|
||||
resourceType: row.resourceType as ShareResourceType,
|
||||
resourceId: row.resourceId,
|
||||
authType: row.authType as ShareAuthType,
|
||||
hasPassword: Boolean(row.password),
|
||||
allowedEmails: Array.isArray(row.allowedEmails) ? (row.allowedEmails as string[]) : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,19 +87,77 @@ interface UpsertFileShareInput {
|
||||
fileId: string
|
||||
userId: string
|
||||
isActive: boolean
|
||||
/** Defaults to the existing share's authType (or `'public'` for a new share). */
|
||||
authType?: ShareAuthType
|
||||
/** Plaintext password to set; encrypted at rest. Required to first enable a password share. */
|
||||
password?: string
|
||||
/** Allowed emails/domains; required to enable an `email`/`sso` share without an existing list. */
|
||||
allowedEmails?: string[]
|
||||
/** Client-reserved token to persist on first insert; ignored when the share already exists. */
|
||||
token?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the public share for a file. First enable inserts a row with
|
||||
* a fresh unguessable token; subsequent calls flip `isActive` and keep the token
|
||||
* stable (so an existing link resolves again after re-enable).
|
||||
* a fresh unguessable token; subsequent calls flip `isActive`/`authType` and keep
|
||||
* the token stable (so an existing link resolves again after re-enable).
|
||||
*
|
||||
* Auth validation only applies when **enabling** (`isActive: true`): `password`
|
||||
* requires a plaintext `password` unless one is already stored (encrypted via
|
||||
* {@link encryptSecret}); `email`/`sso` require a non-empty `allowedEmails`.
|
||||
* Disabling (going Private) always succeeds and preserves the stored config so a
|
||||
* later re-enable restores it. Validation failures throw {@link ShareValidationError}.
|
||||
*/
|
||||
export async function upsertFileShare({
|
||||
workspaceId,
|
||||
fileId,
|
||||
userId,
|
||||
isActive,
|
||||
authType,
|
||||
password,
|
||||
allowedEmails,
|
||||
token,
|
||||
}: UpsertFileShareInput): Promise<ShareRecord> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(publicShare)
|
||||
.where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId)))
|
||||
.limit(1)
|
||||
|
||||
const finalAuthType: ShareAuthType =
|
||||
authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public'
|
||||
const existingAllowedEmails = Array.isArray(existing?.allowedEmails)
|
||||
? (existing.allowedEmails as string[])
|
||||
: []
|
||||
|
||||
// Disabling preserves the stored config (and skips validation) so turning
|
||||
// sharing off always succeeds; only enabling validates the chosen auth mode.
|
||||
let finalPassword: string | null = existing?.password ?? null
|
||||
let finalAllowedEmails: string[] = existingAllowedEmails
|
||||
if (isActive) {
|
||||
if (finalAuthType === 'password') {
|
||||
if (password) {
|
||||
finalPassword = (await encryptSecret(password)).encrypted
|
||||
} else if (existing?.password) {
|
||||
finalPassword = existing.password
|
||||
} else {
|
||||
throw new ShareValidationError('Password is required for password-protected shares')
|
||||
}
|
||||
finalAllowedEmails = []
|
||||
} else if (finalAuthType === 'email' || finalAuthType === 'sso') {
|
||||
finalAllowedEmails = allowedEmails ?? existingAllowedEmails
|
||||
if (finalAllowedEmails.length === 0) {
|
||||
throw new ShareValidationError(
|
||||
'At least one allowed email is required for email/SSO shares'
|
||||
)
|
||||
}
|
||||
finalPassword = null
|
||||
} else {
|
||||
finalPassword = null
|
||||
finalAllowedEmails = []
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(publicShare)
|
||||
.values({
|
||||
@@ -92,16 +166,31 @@ export async function upsertFileShare({
|
||||
resourceId: fileId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
token: generateShortId(),
|
||||
token: token ?? generateShortId(),
|
||||
isActive,
|
||||
authType: finalAuthType,
|
||||
password: finalPassword,
|
||||
allowedEmails: finalAllowedEmails,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [publicShare.resourceType, publicShare.resourceId],
|
||||
set: { isActive, updatedAt: new Date() },
|
||||
set: {
|
||||
isActive,
|
||||
authType: finalAuthType,
|
||||
password: finalPassword,
|
||||
allowedEmails: finalAllowedEmails,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info('Upserted file share', { fileId, workspaceId, isActive, token: row.token })
|
||||
logger.info('Upserted file share', {
|
||||
fileId,
|
||||
workspaceId,
|
||||
isActive,
|
||||
authType: finalAuthType,
|
||||
token: row.token,
|
||||
})
|
||||
return mapShareRecord(row)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "public_share" ADD COLUMN "auth_type" text DEFAULT 'public' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "public_share" ADD COLUMN "password" text;--> statement-breakpoint
|
||||
ALTER TABLE "public_share" ADD COLUMN "allowed_emails" json DEFAULT '[]';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1709,6 +1709,13 @@
|
||||
"when": 1781899910981,
|
||||
"tag": "0244_table_row_executions_enrichment_details",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 245,
|
||||
"version": "7",
|
||||
"when": 1781904859472,
|
||||
"tag": "0245_public_share_auth",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1445,6 +1445,12 @@ export const publicShare = pgTable(
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
token: text('token').notNull(),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
// 'public' (anyone with the link) | 'password' | 'email' (OTP) | 'sso'.
|
||||
authType: text('auth_type').notNull().default('public'),
|
||||
// AES-256-GCM encrypted share password; null unless authType is 'password'.
|
||||
password: text('password'),
|
||||
// Allowed emails/domains (e.g. '@acme.com') when authType is 'email' or 'sso'.
|
||||
allowedEmails: json('allowed_emails').default('[]'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 857,
|
||||
zodRoutes: 857,
|
||||
totalRoutes: 859,
|
||||
zodRoutes: 859,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user