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:
Theodore Li
2026-06-19 15:53:12 -07:00
committed by GitHub
parent 5925651cbc
commit 7349bf403f
44 changed files with 19299 additions and 369 deletions
+1 -1
View File
@@ -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))
}
+3 -1
View File
@@ -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
View File
@@ -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)
})
})
+93 -2
View File
@@ -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) })
}
)
+4 -1
View File
@@ -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
}
+4 -1
View File
@@ -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,
})
}
+93 -5
View File
@@ -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>
)
}
+102
View File
@@ -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>
)
}
+2 -3
View File
@@ -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
+9
View File
@@ -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(' · ')
}
@@ -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>
@@ -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
View File
@@ -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()
}
}
+48 -20
View File
@@ -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(),
+126 -1
View File
@@ -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)
})
})
+27 -8
View File
@@ -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
}
}
+6 -2
View File
@@ -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,
{
+13
View File
@@ -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,
+95 -6
View File
@@ -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
}
]
}
+6
View File
@@ -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(),
},
+2 -2
View File
@@ -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