diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index c7b4f5d0490..ed62dcf7807 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -29,6 +29,7 @@ const { mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, + mockIsEmailAllowed, mockGetStorageMethod, mockZodParse, mockAfterResponse, @@ -48,6 +49,11 @@ const { const mockSendEmail = vi.fn() const mockRenderOTPEmail = vi.fn() const mockSetChatAuthCookie = vi.fn() + const mockIsEmailAllowed = vi.fn((email: string, allowedEmails: string[]) => { + if (allowedEmails.includes(email)) return true + const domain = email.slice(email.indexOf('@') + 1) + return allowedEmails.includes(`@${domain}`) + }) const mockGetStorageMethod = vi.fn() const mockZodParse = vi.fn() const mockAfterResponse = vi.fn() @@ -62,6 +68,7 @@ const { mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, + mockIsEmailAllowed, mockGetStorageMethod, mockZodParse, mockAfterResponse, @@ -101,15 +108,7 @@ vi.mock('@/components/emails', () => ({ })) vi.mock('@/lib/core/security/deployment', () => ({ - isEmailAllowed: (email: string, allowedEmails: string[]) => { - if (allowedEmails.includes(email)) return true - const atIndex = email.indexOf('@') - if (atIndex > 0) { - const domain = email.substring(atIndex + 1) - if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) return true - } - return false - }, + isEmailAllowed: mockIsEmailAllowed, })) vi.mock('@/app/api/chat/utils', () => ({ @@ -173,7 +172,7 @@ describe('Chat OTP API Route', () => { /** Queues the chat-deployment row the route reads before touching OTP storage. */ const queueDeployment = (row: Record) => { - queueTableRows(schemaMock.chat, [row]) + queueTableRows(schemaMock.chat, [{ allowedEmails: [mockEmail], ...row }]) } const emailDeployment = { @@ -483,6 +482,15 @@ describe('Chat OTP API Route', () => { expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) + expect(mockSetChatAuthCookie).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: mockChatId, + authType: 'email', + allowedEmails: [mockEmail], + }), + mockEmail + ) expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) }) }) @@ -514,6 +522,22 @@ describe('Chat OTP API Route', () => { expect(mockRedisGet).not.toHaveBeenCalled() expect(mockSetChatAuthCookie).not.toHaveBeenCalled() }) + + it('rejects verification when the email is no longer allowed', async () => { + mockIsEmailAllowed.mockReturnValueOnce(false) + queueDeployment({ id: mockChatId, authType: 'email' }) + + const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { + method: 'PUT', + body: JSON.stringify({ email: mockEmail, otp: mockOTP }), + }) + + await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) + + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Email not authorized', 403) + expect(mockRedisGet).not.toHaveBeenCalled() + expect(mockSetChatAuthCookie).not.toHaveBeenCalled() + }) }) describe('PUT - Verify OTP (Database path)', () => { diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 8a3747c5ae8..e954ff96f77 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { normalizeEmail } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { getOtpSubject, renderOTPEmail } from '@/components/emails' @@ -103,7 +104,7 @@ export const POST = withRouteHandler( createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), }) if (!parsed.success) return parsed.response - const { email } = parsed.data.body + const email = normalizeEmail(parsed.data.body.email) const deploymentResult = await db .select({ @@ -157,7 +158,8 @@ export const PUT = withRouteHandler( createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400), }) if (!parsed.success) return parsed.response - const { email, otp } = parsed.data.body + const { otp } = parsed.data.body + const email = normalizeEmail(parsed.data.body.email) const deploymentResult = await db .select({ @@ -167,6 +169,7 @@ export const PUT = withRouteHandler( customizations: chat.customizations, authType: chat.authType, password: chat.password, + allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, includeThinking: chat.includeThinking, includeToolCalls: chat.includeToolCalls, @@ -187,6 +190,9 @@ export const PUT = withRouteHandler( if (deployment.authType !== 'email') { return createErrorResponse('This chat does not use email authentication', 400) } + if (!isEmailAllowed(email, deployment.allowedEmails)) { + return createErrorResponse('Email not authorized', 403) + } const storedValue = await getOTP('chat', deployment.id, email) if (!storedValue) { @@ -222,7 +228,7 @@ export const PUT = withRouteHandler( includeThinking: deployment.includeThinking ?? false, includeToolCalls: deployment.includeToolCalls ?? false, }) - setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + setChatAuthCookie(response, deployment, email) return response } catch (error) { diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index cd5834dcea0..5e7f427140a 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -65,13 +65,11 @@ const createMockStream = () => { }) } -const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken, mockProcessChatFiles } = - vi.hoisted(() => ({ - mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), - mockSetChatAuthCookie: vi.fn(), - mockValidateAuthToken: vi.fn().mockReturnValue(false), - mockProcessChatFiles: vi.fn(), - })) +const { mockValidateChatAuth, mockSetChatAuthCookie, mockProcessChatFiles } = vi.hoisted(() => ({ + mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), + mockSetChatAuthCookie: vi.fn(), + mockProcessChatFiles: vi.fn(), +})) const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -82,12 +80,6 @@ vi.mock('@sim/db', () => ({ workflow: {}, })) -vi.mock('@/lib/core/security/deployment', () => ({ - validateAuthToken: mockValidateAuthToken, - setDeploymentAuthCookie: vi.fn(), - isEmailAllowed: vi.fn().mockReturnValue(false), -})) - vi.mock('@/app/api/chat/utils', () => ({ validateChatAuth: mockValidateChatAuth, setChatAuthCookie: mockSetChatAuthCookie, @@ -190,7 +182,6 @@ describe('Chat Identifier API Route', () => { }) mockValidateChatAuth.mockResolvedValue({ authorized: true }) - mockValidateAuthToken.mockReturnValue(false) mockProcessChatFiles.mockResolvedValue([]) mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => { return new Response( @@ -316,6 +307,18 @@ describe('Chat Identifier API Route', () => { describe('POST endpoint', () => { it('should return chat config on successful authentication', async () => { + const passwordDeployment = { + ...mockChatResult[0], + authType: 'password', + password: 'encrypted-password', + } + dbChainMockFns.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([passwordDeployment]), + }), + }), + })) const req = createMockNextRequest('POST', { password: 'test-password' }) const params = Promise.resolve({ identifier: 'password-protected-chat' }) @@ -329,7 +332,7 @@ describe('Chat Identifier API Route', () => { expect(data).toHaveProperty('customizations') expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat') - expect(mockSetChatAuthCookie).toHaveBeenCalled() + expect(mockSetChatAuthCookie).toHaveBeenCalledWith(expect.anything(), passwordDeployment) }) it('should return 400 for requests without input', async () => { diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index ce6e409a9ed..dedc480fc2f 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -9,7 +9,6 @@ import { parseRequest } from '@/lib/api/server' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { env } from '@/lib/core/config/env' -import { validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -158,8 +157,8 @@ export const POST = withRouteHandler( if ((password || email) && !input) { const response = createSuccessResponse(toChatConfigResponse(deployment)) - if (deployment.authType !== 'sso') { - setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + if (deployment.authType === 'password') { + setChatAuthCookie(response, deployment) } return response @@ -394,18 +393,6 @@ export const GET = withRouteHandler( return createErrorResponse('This chat is currently unavailable', 403) } - const cookieName = `chat_auth_${deployment.id}` - const authCookie = request.cookies.get(cookieName) - - if ( - deployment.authType !== 'public' && - deployment.authType !== 'sso' && - authCookie && - validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password) - ) { - return createSuccessResponse(toChatConfigResponse(deployment)) - } - const authResult = await validateChatAuth(requestId, deployment, request) if (!authResult.authorized) { logger.info( diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 5b5675763a1..45993ea5aca 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -100,12 +100,10 @@ describe('Chat API Utils', () => { } as any const result = await validateChatAuth('request-id', deployment, mockRequest) - expect(mockValidateAuthToken).toHaveBeenCalledWith( - 'valid-token', - 'chat-id', - 'password', - 'encrypted-password' - ) + expect(mockValidateAuthToken).toHaveBeenCalledWith({ + token: 'valid-token', + resource: deployment, + }) expect(result.authorized).toBe(true) }) @@ -136,15 +134,19 @@ describe('Chat API Utils', () => { cookies: { set: vi.fn() }, } as unknown as NextResponse - setChatAuthCookie(mockResponse, 'test-chat-id', 'password') + const deployment = { + id: 'test-chat-id', + authType: 'password', + password: 'encrypted-password', + } + setChatAuthCookie(mockResponse, deployment) - expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith( - mockResponse, - 'chat', - 'test-chat-id', - 'password', - undefined - ) + expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ + response: mockResponse, + cookiePrefix: 'chat', + resource: deployment, + verifiedEmail: undefined, + }) }) }) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 5b17f3cb6e8..55dd06e36a6 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -3,19 +3,27 @@ import { chat, workflow } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' -import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' import { + type DeploymentAuthResource, + setDeploymentAuthCookie, +} from '@/lib/core/security/deployment' +import { + type DeploymentAuthBody, type DeploymentAuthResult, validateDeploymentAuth, } from '@/lib/core/security/deployment-auth' export function setChatAuthCookie( response: NextResponse, - chatId: string, - type: string, - encryptedPassword?: string | null + deployment: DeploymentAuthResource, + verifiedEmail?: string ): void { - setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword) + setDeploymentAuthCookie({ + response, + cookiePrefix: 'chat', + resource: deployment, + verifiedEmail, + }) } /** @@ -85,9 +93,9 @@ export async function checkChatAccess( */ export async function validateChatAuth( requestId: string, - deployment: any, + deployment: DeploymentAuthResource, request: NextRequest, - parsedBody?: any + parsedBody?: DeploymentAuthBody ): Promise { return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat') } diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts index 94429804c40..7219678f6cc 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.test.ts @@ -223,6 +223,7 @@ describe('PUT /api/files/public/[token]/otp', () => { beforeEach(() => { vi.clearAllMocks() mockResolveActiveShareByToken.mockResolvedValue(emailShare) + mockIsEmailAllowed.mockReturnValue(true) mockGetOTP.mockResolvedValue('123456:0') mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 }) }) @@ -232,13 +233,23 @@ describe('PUT /api/files/public/[token]/otp', () => { 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 - ) + expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ + response: expect.anything(), + cookiePrefix: 'file', + resource: emailShare.share, + verifiedEmail: 'user@acme.com', + }) + }) + + it('rejects a valid code when the email is no longer allowed', async () => { + mockIsEmailAllowed.mockReturnValueOnce(false) + + const res = await PUT(put('user@acme.com', '123456'), params()) + + expect(res.status).toBe(403) + expect(mockGetOTP).not.toHaveBeenCalled() + expect(mockDeleteOTP).not.toHaveBeenCalled() + expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() }) it('rejects a wrong code with 400 and increments attempts', async () => { diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 86c871375e1..ef6408c90fe 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -36,11 +36,6 @@ 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.' }, @@ -131,7 +126,7 @@ export const POST = withRouteHandler( { status: 400 } ) } - const emailAllowed = isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails)) + const emailAllowed = isEmailAllowed(email, resolved.share.allowedEmails) afterResponse(async () => { if (!emailAllowed) return @@ -170,6 +165,9 @@ export const PUT = withRouteHandler( { status: 400 } ) } + if (!isEmailAllowed(email, resolved.share.allowedEmails)) { + return NextResponse.json({ error: 'Email not authorized' }, { status: 403 }) + } const storedValue = await getOTP('file', resolved.share.id, email) if (!storedValue) { @@ -202,13 +200,12 @@ export const PUT = withRouteHandler( await deleteOTP('file', resolved.share.id, email) const response = NextResponse.json({ authType: resolved.share.authType }) - setDeploymentAuthCookie( + setDeploymentAuthCookie({ response, - 'file', - resolved.share.id, - resolved.share.authType, - resolved.share.password - ) + cookiePrefix: 'file', + resource: resolved.share, + verifiedEmail: email, + }) logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`) return response } catch (error) { diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts index 3d48d974589..559692faf25 100644 --- a/apps/sim/app/api/files/public/[token]/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/route.test.ts @@ -140,13 +140,11 @@ describe('POST /api/files/public/[token]', () => { 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' - ) + expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ + response: expect.anything(), + cookiePrefix: 'file', + resource: passwordShare.share, + }) }) it('refuses to mint a cookie for a non-password (e.g. public) share', async () => { diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index be95a9b5964..ef27b83063b 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -124,13 +124,11 @@ export const POST = withRouteHandler( } const response = NextResponse.json({ authType: resolved.share.authType }) - setDeploymentAuthCookie( + setDeploymentAuthCookie({ response, - 'file', - resolved.share.id, - resolved.share.authType, - resolved.share.password - ) + cookiePrefix: 'file', + resource: resolved.share, + }) logger.info('Public file share password accepted', { token, shareId: resolved.share.id }) return response } catch (error) { diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 642b7975bb4..1db478d98f1 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -91,7 +91,7 @@ async function renderAuthGate(token: string, share: GateShare) { const cookieStore = await cookies() const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value - if (validateAuthToken(cookieValue ?? '', share.id, share.authType, share.password)) return null + if (validateAuthToken({ token: cookieValue ?? '', resource: share })) return null return share.authType === 'email' ? ( diff --git a/apps/sim/lib/core/security/deployment-auth.ts b/apps/sim/lib/core/security/deployment-auth.ts index 69290f04f15..29da0392bd7 100644 --- a/apps/sim/lib/core/security/deployment-auth.ts +++ b/apps/sim/lib/core/security/deployment-auth.ts @@ -5,6 +5,7 @@ import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { type DeploymentAuthKind, + type DeploymentAuthResource, deploymentAuthCookieName, isEmailAllowed, validateAuthToken, @@ -49,18 +50,7 @@ function passwordRateLimitResult( } } -/** - * 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 { +export interface DeploymentAuthBody { password?: string email?: string input?: unknown @@ -92,13 +82,10 @@ export async function validateDeploymentAuth( return { authorized: true } } - if (authType !== 'sso') { + if (authType === 'password' || authType === 'email') { const authCookie = request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id)) - if ( - authCookie && - validateAuthToken(authCookie.value, resource.id, authType, resource.password) - ) { + if (authCookie && validateAuthToken({ token: authCookie.value, resource })) { return { authorized: true } } } @@ -196,9 +183,7 @@ export async function validateDeploymentAuth( return { authorized: false, error: 'Email is required' } } - const allowedEmails = (resource.allowedEmails as string[]) || [] - - if (isEmailAllowed(email, allowedEmails)) { + if (isEmailAllowed(email, resource.allowedEmails)) { return { authorized: false, error: 'otp_required' } } @@ -227,9 +212,7 @@ export async function validateDeploymentAuth( return { authorized: false, error: 'SSO session does not contain email' } } - const allowedEmails = (resource.allowedEmails as string[]) || [] - - if (isEmailAllowed(userEmail, allowedEmails)) { + if (isEmailAllowed(userEmail, resource.allowedEmails)) { return { authorized: true } } diff --git a/apps/sim/lib/core/security/deployment.test.ts b/apps/sim/lib/core/security/deployment.test.ts index 45032867c13..18b379fb521 100644 --- a/apps/sim/lib/core/security/deployment.test.ts +++ b/apps/sim/lib/core/security/deployment.test.ts @@ -1,8 +1,168 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import { isEmailAllowed } from '@/lib/core/security/deployment' +import { NextResponse } from 'next/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type DeploymentAuthResource, + deploymentAuthCookieName, + isEmailAllowed, + setDeploymentAuthCookie, + validateAuthToken, +} from '@/lib/core/security/deployment' + +const DAY_MS = 24 * 60 * 60 * 1000 + +function mintToken(resource: DeploymentAuthResource, verifiedEmail?: string): string { + const response = NextResponse.json({}) + setDeploymentAuthCookie({ + response, + cookiePrefix: 'file', + resource, + verifiedEmail, + }) + const token = response.cookies.get(deploymentAuthCookieName('file', resource.id))?.value + if (!token) throw new Error('Expected deployment auth cookie') + return token +} + +describe('deployment auth tokens', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('binds a password token to the resource, auth mode, and current password', () => { + const resource = { + id: 'share-1', + authType: 'password', + password: 'encrypted-password-1', + } + const token = mintToken(resource) + + expect(validateAuthToken({ token, resource })).toBe(true) + expect(validateAuthToken({ token, resource: { ...resource, id: 'share-2' } })).toBe(false) + expect(validateAuthToken({ token, resource: { ...resource, authType: 'email' } })).toBe(false) + expect( + validateAuthToken({ + token, + resource: { ...resource, password: 'encrypted-password-2' }, + }) + ).toBe(false) + }) + + it('revokes an exact-address email token as soon as that address is removed', () => { + const resource = { + id: 'share-1', + authType: 'email', + password: null, + allowedEmails: ['viewer@example.test', 'other@example.test'], + } + const token = mintToken(resource, 'Viewer@Example.Test') + + expect(validateAuthToken({ token, resource })).toBe(true) + expect( + validateAuthToken({ + token, + resource: { ...resource, allowedEmails: ['other@example.test'] }, + }) + ).toBe(false) + }) + + it('keeps an email token valid while its exact or domain grant remains current', () => { + const resource = { + id: 'share-1', + authType: 'email', + password: null, + allowedEmails: ['viewer@example.test'], + } + const token = mintToken(resource, 'viewer@example.test') + + expect( + validateAuthToken({ + token, + resource: { ...resource, allowedEmails: ['new@example.test', 'viewer@example.test'] }, + }) + ).toBe(true) + expect( + validateAuthToken({ + token, + resource: { ...resource, allowedEmails: ['@example.test'] }, + }) + ).toBe(true) + }) + + it('revokes a domain-granted token when the domain is removed', () => { + const resource = { + id: 'share-1', + authType: 'email', + password: null, + allowedEmails: ['@example.test'], + } + const token = mintToken(resource, 'viewer@example.test') + + expect(validateAuthToken({ token, resource })).toBe(true) + expect( + validateAuthToken({ + token, + resource: { ...resource, allowedEmails: ['@other.test'] }, + }) + ).toBe(false) + }) + + it('does not expose the verified email address in the signed payload', () => { + const token = mintToken( + { + id: 'share-1', + authType: 'email', + password: null, + allowedEmails: ['viewer@example.test'], + }, + 'viewer@example.test' + ) + const [encodedPayload] = token.split('.') + const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8') + + expect(decodedPayload).not.toContain('viewer') + expect(decodedPayload).not.toContain('example.test') + }) + + it('rejects expired, future-dated, malformed, and legacy tokens', () => { + const now = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now) + const resource = { + id: 'share-1', + authType: 'password', + password: 'encrypted-password-1', + } + const token = mintToken(resource) + + nowSpy.mockReturnValue(now + DAY_MS + 1) + expect(validateAuthToken({ token, resource })).toBe(false) + nowSpy.mockReturnValue(now - 60_001) + expect(validateAuthToken({ token, resource })).toBe(false) + expect(validateAuthToken({ token: `${token}tampered`, resource })).toBe(false) + expect(validateAuthToken({ token: 'legacy-token', resource })).toBe(false) + }) + + it('requires the credential that corresponds to the selected auth mode', () => { + const response = NextResponse.json({}) + + expect(() => + setDeploymentAuthCookie({ + response, + cookiePrefix: 'chat', + resource: { id: 'chat-1', authType: 'email', allowedEmails: ['viewer@example.test'] }, + }) + ).toThrow('verified email') + expect(() => + setDeploymentAuthCookie({ + response, + cookiePrefix: 'chat', + resource: { id: 'chat-1', authType: 'password', password: null }, + }) + ).toThrow('configured password') + }) +}) describe('isEmailAllowed', () => { it('matches an exact email regardless of casing on either side', () => { @@ -12,13 +172,14 @@ describe('isEmailAllowed', () => { expect(isEmailAllowed(' User@Acme.com ', ['user@acme.com'])).toBe(true) }) - it('matches a domain pattern regardless of casing (covers IdP/session emails)', () => { + it('matches a domain pattern regardless of casing', () => { 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) + it('rejects invalid input and non-string persisted entries', () => { + expect(isEmailAllowed('invalid', ['invalid'])).toBe(false) + expect(isEmailAllowed('user@acme.com', ['user@evil.com', 123])).toBe(false) + expect(isEmailAllowed('user@acme.com', null)).toBe(false) }) }) diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index c87b49a20d2..b9bac6a739a 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -1,134 +1,245 @@ import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' import { hmacSha256Hex } from '@sim/security/hmac' -import { normalizeEmail } from '@sim/utils/string' +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import type { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { isDev } from '@/lib/core/config/env-flags' -/** - * Shared authentication utilities for deployed chat endpoints. - * Handles token generation, validation, and auth cookies. CORS for these - * endpoints lives in proxy.ts as the single source of truth. - */ +const DEPLOYMENT_AUTH_TOKEN_VERSION = 1 +const DEPLOYMENT_AUTH_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 +const DEPLOYMENT_AUTH_TOKEN_CLOCK_SKEW_MS = 60 * 1000 +const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/ + +/** The kind of deployed resource an auth cookie/token belongs to. */ +export type DeploymentAuthKind = 'chat' | 'file' + +/** The current auth-policy fields needed to mint or validate a deployment cookie. */ +export interface DeploymentAuthResource { + id: string + authType: string | null + password?: string | null + allowedEmails?: unknown +} + +interface DeploymentAuthTokenBase { + version: typeof DEPLOYMENT_AUTH_TOKEN_VERSION + resourceId: string + issuedAt: number +} + +interface PasswordAuthTokenPayload extends DeploymentAuthTokenBase { + authType: 'password' + passwordSlot: string +} + +interface EmailAuthTokenPayload extends DeploymentAuthTokenBase { + authType: 'email' + emailSlot: string + emailDomainSlot: string +} + +type DeploymentAuthTokenPayload = PasswordAuthTokenPayload | EmailAuthTokenPayload + +interface EmailGrant { + kind: 'email' | 'domain' + value: string +} + +interface ValidateAuthTokenParams { + token: string + resource: DeploymentAuthResource +} + +interface SetDeploymentAuthCookieParams { + response: NextResponse + cookiePrefix: DeploymentAuthKind + resource: DeploymentAuthResource + verifiedEmail?: string +} function signPayload(payload: string): string { return hmacSha256Hex(payload, env.BETTER_AUTH_SECRET) } -function passwordSlot(encryptedPassword?: string | null): string { - if (!encryptedPassword) return '' - return sha256Hex(encryptedPassword).slice(0, 8) +function passwordSlot(encryptedPassword: string): string { + return sha256Hex(encryptedPassword) } -function generateAuthToken( - deploymentId: string, - type: string, - encryptedPassword?: string | null -): string { - const payload = `${deploymentId}:${type}:${Date.now()}:${passwordSlot(encryptedPassword)}` - const sig = signPayload(payload) - return Buffer.from(`${payload}:${sig}`).toString('base64') +function identitySlot(kind: 'email' | 'domain', value: string): string { + return hmacSha256Hex(`deployment-auth:${kind}:${value}`, env.BETTER_AUTH_SECRET) } -/** - * Validates an HMAC-signed authentication token for a chat deployment. - * Includes a password-derived slot so changing the deployment password immediately - * invalidates existing sessions. - */ -export function validateAuthToken( - token: string, - deploymentId: string, - authType: string, - encryptedPassword?: string | null -): boolean { - try { - const decoded = Buffer.from(token, 'base64').toString() - const lastColon = decoded.lastIndexOf(':') - if (lastColon === -1) return false +function emailIdentitySlots( + email: string +): Pick { + const normalizedEmail = normalizeEmail(email) + if (!isValidEmailSyntax(normalizedEmail)) { + throw new Error('Cannot create deployment auth token for an invalid email address') + } - const payload = decoded.slice(0, lastColon) - const sig = decoded.slice(lastColon + 1) + const domain = normalizedEmail.slice(normalizedEmail.lastIndexOf('@') + 1) + return { + emailSlot: identitySlot('email', normalizedEmail), + emailDomainSlot: identitySlot('domain', domain), + } +} - const expectedSig = signPayload(payload) - if (!safeCompare(sig, expectedSig)) { - return false +function emailGrants(allowedEmails: unknown): EmailGrant[] { + if (!Array.isArray(allowedEmails)) return [] + + const grants: EmailGrant[] = [] + for (const entry of allowedEmails) { + if (typeof entry !== 'string') continue + const normalizedEntry = normalizeEmail(entry) + if (normalizedEntry.startsWith('@')) { + if (isValidEmailSyntax(normalizedEntry, true)) { + grants.push({ kind: 'domain', value: normalizedEntry.slice(1) }) + } + } else if (isValidEmailSyntax(normalizedEntry)) { + grants.push({ kind: 'email', value: normalizedEntry }) } + } + return grants +} - const parts = payload.split(':') - if (parts.length < 4) return false - const [storedId, storedType, timestamp, storedPwSlot] = parts +function generateAuthToken(resource: DeploymentAuthResource, verifiedEmail?: string): string { + const base = { + version: DEPLOYMENT_AUTH_TOKEN_VERSION, + resourceId: resource.id, + issuedAt: Date.now(), + } as const + + let payload: DeploymentAuthTokenPayload + if (resource.authType === 'password') { + if (!resource.password) { + throw new Error('Cannot create password auth token without a configured password') + } + payload = { + ...base, + authType: 'password', + passwordSlot: passwordSlot(resource.password), + } + } else if (resource.authType === 'email') { + if (!verifiedEmail) { + throw new Error('Cannot create email auth token without a verified email address') + } + payload = { + ...base, + authType: 'email', + ...emailIdentitySlots(verifiedEmail), + } + } else { + throw new Error(`Cannot create auth token for unsupported auth type: ${resource.authType}`) + } - if (storedId !== deploymentId) return false + const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + return `${encodedPayload}.${signPayload(encodedPayload)}` +} + +function isSha256Hex(value: unknown): value is string { + return typeof value === 'string' && SHA256_HEX_PATTERN.test(value) +} + +function isDeploymentAuthTokenPayload(value: unknown): value is DeploymentAuthTokenPayload { + if (!value || typeof value !== 'object') return false + const payload = value as Record + if ( + payload.version !== DEPLOYMENT_AUTH_TOKEN_VERSION || + typeof payload.resourceId !== 'string' || + payload.resourceId.length === 0 || + typeof payload.issuedAt !== 'number' || + !Number.isSafeInteger(payload.issuedAt) + ) { + return false + } + + if (payload.authType === 'password') { + return isSha256Hex(payload.passwordSlot) + } + if (payload.authType === 'email') { + return isSha256Hex(payload.emailSlot) && isSha256Hex(payload.emailDomainSlot) + } + return false +} + +function isEmailTokenAllowed(payload: EmailAuthTokenPayload, allowedEmails: unknown): boolean { + return emailGrants(allowedEmails).some((grant) => { + const tokenSlot = grant.kind === 'domain' ? payload.emailDomainSlot : payload.emailSlot + return safeCompare(tokenSlot, identitySlot(grant.kind, grant.value)) + }) +} + +/** + * Validates a signed deployment cookie against the resource's current auth policy. + * Email tokens carry HMAC-derived identity slots so allow-list removals take effect + * immediately without exposing the verified address in the cookie. + */ +export function validateAuthToken({ token, resource }: ValidateAuthTokenParams): boolean { + try { + const [encodedPayload, signature, extra] = token.split('.') + if (!encodedPayload || !signature || extra !== undefined) 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 expectedSignature = signPayload(encodedPayload) + if (!safeCompare(signature, expectedSignature)) return false - const expectedPwSlot = passwordSlot(encryptedPassword) - if (storedPwSlot !== expectedPwSlot) return false + const decoded: unknown = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) + if (!isDeploymentAuthTokenPayload(decoded)) return false + if (decoded.resourceId !== resource.id || decoded.authType !== resource.authType) return false - const createdAt = Number.parseInt(timestamp) - const expireTime = 24 * 60 * 60 * 1000 - if (Date.now() - createdAt > expireTime) return false + const now = Date.now() + if ( + decoded.issuedAt > now + DEPLOYMENT_AUTH_TOKEN_CLOCK_SKEW_MS || + now - decoded.issuedAt > DEPLOYMENT_AUTH_TOKEN_TTL_MS + ) { + return false + } + + if (decoded.authType === 'password') { + return Boolean( + resource.password && safeCompare(decoded.passwordSlot, passwordSlot(resource.password)) + ) + } - return true - } catch (_e) { + return isEmailTokenAllowed(decoded, resource.allowedEmails) + } catch { return false } } -/** 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: DeploymentAuthKind, - deploymentId: string, - authType: string, - encryptedPassword?: string | null -): void { - const token = generateAuthToken(deploymentId, authType, encryptedPassword) +/** Sets a signed, resource-bound authentication cookie for a deployment. */ +export function setDeploymentAuthCookie({ + response, + cookiePrefix, + resource, + verifiedEmail, +}: SetDeploymentAuthCookieParams): void { response.cookies.set({ - name: deploymentAuthCookieName(cookiePrefix, deploymentId), - value: token, + name: deploymentAuthCookieName(cookiePrefix, resource.id), + value: generateAuthToken(resource, verifiedEmail), httpOnly: true, secure: !isDev, sameSite: 'lax', path: '/', - maxAge: 60 * 60 * 24, + maxAge: DEPLOYMENT_AUTH_TOKEN_TTL_MS / 1000, }) } /** - * 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. + * Checks whether an email matches an exact address or domain in an allow-list. + * Invalid persisted entries are ignored rather than weakening or breaking the gate. */ -export function isEmailAllowed(email: string, allowedEmails: string[]): boolean { +export function isEmailAllowed(email: string, allowedEmails: unknown): boolean { const normalizedEmail = normalizeEmail(email) - const normalizedAllowed = allowedEmails.map(normalizeEmail) - - if (normalizedAllowed.includes(normalizedEmail)) { - return true - } - - const atIndex = normalizedEmail.indexOf('@') - if (atIndex > 0) { - const domain = normalizedEmail.substring(atIndex + 1) - if (domain && normalizedAllowed.some((allowed) => allowed === `@${domain}`)) { - return true - } - } + if (!isValidEmailSyntax(normalizedEmail)) return false - return false + const domain = normalizedEmail.slice(normalizedEmail.lastIndexOf('@') + 1) + return emailGrants(allowedEmails).some((grant) => { + return grant.kind === 'email' ? grant.value === normalizedEmail : grant.value === domain + }) }