Skip to content

Commit 7349bf4

Browse files
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
1 parent 5925651 commit 7349bf4

44 files changed

Lines changed: 19298 additions & 368 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ export const GET = withRouteHandler(
366366
deployment.authType !== 'public' &&
367367
deployment.authType !== 'sso' &&
368368
authCookie &&
369-
validateAuthToken(authCookie.value, deployment.id, deployment.password)
369+
validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password)
370370
) {
371371
return createSuccessResponse(toChatConfigResponse(deployment))
372372
}

apps/sim/app/api/chat/utils.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ vi.mock('@/lib/core/security/deployment', () => ({
7676
validateAuthToken: mockValidateAuthToken,
7777
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
7878
isEmailAllowed: mockIsEmailAllowed,
79+
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
7980
}))
8081

8182
vi.mock('@/lib/core/config/env-flags', () => ({
@@ -134,6 +135,7 @@ describe('Chat API Utils', () => {
134135
expect(mockValidateAuthToken).toHaveBeenCalledWith(
135136
'valid-token',
136137
'chat-id',
138+
'password',
137139
'encrypted-password'
138140
)
139141
expect(result.authorized).toBe(true)
@@ -407,7 +409,7 @@ describe('Chat API Utils', () => {
407409
})
408410

409411
expect(result.authorized).toBe(false)
410-
expect(result.error).toBe('Your email is not authorized to access this chat')
412+
expect(result.error).toBe('Your email is not authorized to access this resource')
411413
})
412414
})
413415
})

apps/sim/app/api/chat/utils.ts

Lines changed: 10 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,20 @@ import { db } from '@sim/db'
22
import { chat, workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
5-
import { safeCompare } from '@sim/security/compare'
65
import { and, eq, isNull } from 'drizzle-orm'
76
import type { NextRequest, NextResponse } from 'next/server'
87
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
98
import { getEnv } from '@/lib/core/config/env'
109
import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
11-
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
12-
import { RateLimiter } from '@/lib/core/rate-limiter'
10+
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
1311
import {
14-
isEmailAllowed,
15-
setDeploymentAuthCookie,
16-
validateAuthToken,
17-
} from '@/lib/core/security/deployment'
18-
import { decryptSecret } from '@/lib/core/security/encryption'
19-
import { getClientIp } from '@/lib/core/utils/request'
12+
type DeploymentAuthResult,
13+
validateDeploymentAuth,
14+
} from '@/lib/core/security/deployment-auth'
2015
import { createErrorResponse } from '@/app/api/workflows/utils'
2116

2217
const logger = createLogger('ChatAuthUtils')
2318

24-
const rateLimiter = new RateLimiter()
25-
26-
/**
27-
* Throttles unauthenticated password guesses per client IP against a single
28-
* deployment, mirroring the OTP/SSO IP limits.
29-
*/
30-
const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = {
31-
maxTokens: 10,
32-
refillRate: 10,
33-
refillIntervalMs: 15 * 60_000,
34-
}
35-
3619
export function setChatAuthCookie(
3720
response: NextResponse,
3821
chatId: string,
@@ -157,144 +140,15 @@ export async function checkChatAccess(
157140
: { hasAccess: false }
158141
}
159142

143+
/**
144+
* Validates auth for a deployed chat. Thin wrapper over the shared
145+
* {@link validateDeploymentAuth} with the `'chat'` cookie/rate-limit namespace.
146+
*/
160147
export async function validateChatAuth(
161148
requestId: string,
162149
deployment: any,
163150
request: NextRequest,
164151
parsedBody?: any
165-
): Promise<{ authorized: boolean; error?: string; status?: number; retryAfterMs?: number }> {
166-
const authType = deployment.authType || 'public'
167-
168-
if (authType === 'public') {
169-
return { authorized: true }
170-
}
171-
172-
if (authType !== 'sso') {
173-
const cookieName = `chat_auth_${deployment.id}`
174-
const authCookie = request.cookies.get(cookieName)
175-
176-
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
177-
return { authorized: true }
178-
}
179-
}
180-
181-
if (authType === 'password') {
182-
if (request.method === 'GET') {
183-
return { authorized: false, error: 'auth_required_password' }
184-
}
185-
186-
try {
187-
if (!parsedBody) {
188-
return { authorized: false, error: 'Password is required' }
189-
}
190-
191-
const { password, input } = parsedBody
192-
193-
if (input && !password) {
194-
return { authorized: false, error: 'auth_required_password' }
195-
}
196-
197-
if (!password) {
198-
return { authorized: false, error: 'Password is required' }
199-
}
200-
201-
if (!deployment.password) {
202-
logger.error(`[${requestId}] No password set for password-protected chat: ${deployment.id}`)
203-
return { authorized: false, error: 'Authentication configuration error' }
204-
}
205-
206-
const ip = getClientIp(request)
207-
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
208-
`chat-password:ip:${deployment.id}:${ip}`,
209-
PASSWORD_IP_RATE_LIMIT
210-
)
211-
if (!ipRateLimit.allowed) {
212-
logger.warn(
213-
`[${requestId}] Password attempt IP rate limit exceeded for chat ${deployment.id} from ${ip}`
214-
)
215-
return {
216-
authorized: false,
217-
error: 'Too many attempts. Please try again later.',
218-
status: 429,
219-
retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs,
220-
}
221-
}
222-
223-
const { decrypted } = await decryptSecret(deployment.password)
224-
if (!safeCompare(password, decrypted)) {
225-
return { authorized: false, error: 'Invalid password' }
226-
}
227-
228-
return { authorized: true }
229-
} catch (error) {
230-
logger.error(`[${requestId}] Error validating password:`, error)
231-
return { authorized: false, error: 'Authentication error' }
232-
}
233-
}
234-
235-
if (authType === 'email') {
236-
if (request.method === 'GET') {
237-
return { authorized: false, error: 'auth_required_email' }
238-
}
239-
240-
try {
241-
if (!parsedBody) {
242-
return { authorized: false, error: 'Email is required' }
243-
}
244-
245-
const { email, input } = parsedBody
246-
247-
if (input && !email) {
248-
return { authorized: false, error: 'auth_required_email' }
249-
}
250-
251-
if (!email) {
252-
return { authorized: false, error: 'Email is required' }
253-
}
254-
255-
const allowedEmails = deployment.allowedEmails || []
256-
257-
if (isEmailAllowed(email, allowedEmails)) {
258-
return { authorized: false, error: 'otp_required' }
259-
}
260-
261-
return { authorized: false, error: 'Email not authorized' }
262-
} catch (error) {
263-
logger.error(`[${requestId}] Error validating email:`, error)
264-
return { authorized: false, error: 'Authentication error' }
265-
}
266-
}
267-
268-
if (authType === 'sso') {
269-
try {
270-
if (request.method !== 'GET' && !parsedBody) {
271-
return { authorized: false, error: 'SSO authentication is required' }
272-
}
273-
274-
const { getSession } = await import('@/lib/auth')
275-
const session = await getSession()
276-
277-
if (!session || !session.user) {
278-
return { authorized: false, error: 'auth_required_sso' }
279-
}
280-
281-
const userEmail = session.user.email
282-
if (!userEmail) {
283-
return { authorized: false, error: 'SSO session does not contain email' }
284-
}
285-
286-
const allowedEmails = deployment.allowedEmails || []
287-
288-
if (isEmailAllowed(userEmail, allowedEmails)) {
289-
return { authorized: true }
290-
}
291-
292-
return { authorized: false, error: 'Your email is not authorized to access this chat' }
293-
} catch (error) {
294-
logger.error(`[${requestId}] Error validating SSO:`, error)
295-
return { authorized: false, error: 'SSO authentication error' }
296-
}
297-
}
298-
299-
return { authorized: false, error: 'Unsupported authentication type' }
152+
): Promise<DeploymentAuthResult> {
153+
return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat')
300154
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockResolveActiveShareByToken,
9+
mockEnforceRateLimit,
10+
mockValidateDeploymentAuth,
11+
mockDownloadFile,
12+
mockResolveServableDoc,
13+
} = vi.hoisted(() => ({
14+
mockResolveActiveShareByToken: vi.fn(),
15+
mockEnforceRateLimit: vi.fn(),
16+
mockValidateDeploymentAuth: vi.fn(),
17+
mockDownloadFile: vi.fn(),
18+
mockResolveServableDoc: vi.fn(),
19+
}))
20+
21+
vi.mock('@/lib/public-shares/share-manager', () => ({
22+
resolveActiveShareByToken: mockResolveActiveShareByToken,
23+
}))
24+
25+
vi.mock('@/lib/public-shares/rate-limit', () => ({
26+
enforcePublicFileRateLimit: mockEnforceRateLimit,
27+
}))
28+
29+
vi.mock('@/lib/core/security/deployment-auth', () => ({
30+
validateDeploymentAuth: mockValidateDeploymentAuth,
31+
}))
32+
33+
vi.mock('@/lib/uploads/core/storage-service', () => ({
34+
downloadFile: mockDownloadFile,
35+
}))
36+
37+
vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({
38+
resolveServableDoc: mockResolveServableDoc,
39+
}))
40+
41+
import { GET } from '@/app/api/files/public/[token]/content/route'
42+
43+
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
44+
const request = (token = 'tok_1') =>
45+
new NextRequest(`http://localhost/api/files/public/${token}/content`)
46+
47+
const passwordShare = {
48+
share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' },
49+
file: {
50+
id: 'wf_1',
51+
key: 'workspace/ws/secret-key.pdf',
52+
workspaceId: 'ws-1',
53+
originalName: 'report.pdf',
54+
contentType: 'application/pdf',
55+
size: 4,
56+
},
57+
workspaceName: 'Acme',
58+
ownerName: 'Jane',
59+
}
60+
61+
describe('GET /api/files/public/[token]/content', () => {
62+
beforeEach(() => {
63+
vi.clearAllMocks()
64+
mockEnforceRateLimit.mockResolvedValue(null)
65+
mockResolveActiveShareByToken.mockResolvedValue(passwordShare)
66+
mockDownloadFile.mockResolvedValue(Buffer.from('data'))
67+
mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' })
68+
})
69+
70+
it('returns 401 and never reads storage when a password share is unauthorized', async () => {
71+
mockValidateDeploymentAuth.mockResolvedValueOnce({
72+
authorized: false,
73+
error: 'auth_required_password',
74+
})
75+
const res = await GET(request(), params())
76+
expect(res.status).toBe(401)
77+
expect((await res.json()).error).toBe('auth_required_password')
78+
expect(mockDownloadFile).not.toHaveBeenCalled()
79+
})
80+
81+
it('serves the bytes once authorized', async () => {
82+
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
83+
const res = await GET(request(), params())
84+
expect(res.status).toBe(200)
85+
expect(mockDownloadFile).toHaveBeenCalledWith({
86+
key: passwordShare.file.key,
87+
context: 'workspace',
88+
})
89+
})
90+
})

apps/sim/app/api/files/public/[token]/content/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'
44
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
55
import { parseRequest } from '@/lib/api/server'
66
import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile'
7+
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
8+
import { generateRequestId } from '@/lib/core/utils/request'
79
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
810
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
911
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
@@ -28,6 +30,8 @@ const logger = createLogger('PublicFileContentAPI')
2830
*/
2931
export const GET = withRouteHandler(
3032
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
33+
const requestId = generateRequestId()
34+
3135
try {
3236
const limited = await enforcePublicFileRateLimit(request, 'content')
3337
if (limited) return limited
@@ -41,6 +45,17 @@ export const GET = withRouteHandler(
4145
throw new FileNotFoundError('Not found')
4246
}
4347

48+
const auth = await validateDeploymentAuth(
49+
requestId,
50+
resolved.share,
51+
request,
52+
undefined,
53+
'file'
54+
)
55+
if (!auth.authorized) {
56+
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
57+
}
58+
4459
const { file } = resolved
4560
const raw = await downloadFile({ key: file.key, context: 'workspace' })
4661

0 commit comments

Comments
 (0)