Skip to content

Commit c8fc0ac

Browse files
committed
fix(security): add authentication to tool API routes
1 parent be7f3db commit c8fc0ac

File tree

74 files changed

+729
-105
lines changed

Some content is hidden

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

74 files changed

+729
-105
lines changed

apps/sim/app/api/auth/oauth/token/route.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe('OAuth Token API Routes', () => {
1010
const mockGetUserId = vi.fn()
1111
const mockGetCredential = vi.fn()
1212
const mockRefreshTokenIfNeeded = vi.fn()
13+
const mockGetOAuthToken = vi.fn()
1314
const mockAuthorizeCredentialUse = vi.fn()
1415
const mockCheckHybridAuth = vi.fn()
1516

@@ -29,6 +30,7 @@ describe('OAuth Token API Routes', () => {
2930
getUserId: mockGetUserId,
3031
getCredential: mockGetCredential,
3132
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
33+
getOAuthToken: mockGetOAuthToken,
3234
}))
3335

3436
vi.doMock('@sim/logger', () => ({
@@ -230,6 +232,140 @@ describe('OAuth Token API Routes', () => {
230232
expect(response.status).toBe(401)
231233
expect(data).toHaveProperty('error', 'Failed to refresh access token')
232234
})
235+
236+
describe('credentialAccountUserId + providerId path', () => {
237+
it('should reject unauthenticated requests', async () => {
238+
mockCheckHybridAuth.mockResolvedValueOnce({
239+
success: false,
240+
error: 'Authentication required',
241+
})
242+
243+
const req = createMockRequest('POST', {
244+
credentialAccountUserId: 'target-user-id',
245+
providerId: 'google',
246+
})
247+
248+
const { POST } = await import('@/app/api/auth/oauth/token/route')
249+
250+
const response = await POST(req)
251+
const data = await response.json()
252+
253+
expect(response.status).toBe(401)
254+
expect(data).toHaveProperty('error', 'User not authenticated')
255+
expect(mockGetOAuthToken).not.toHaveBeenCalled()
256+
})
257+
258+
it('should reject API key authentication', async () => {
259+
mockCheckHybridAuth.mockResolvedValueOnce({
260+
success: true,
261+
authType: 'api_key',
262+
userId: 'test-user-id',
263+
})
264+
265+
const req = createMockRequest('POST', {
266+
credentialAccountUserId: 'test-user-id',
267+
providerId: 'google',
268+
})
269+
270+
const { POST } = await import('@/app/api/auth/oauth/token/route')
271+
272+
const response = await POST(req)
273+
const data = await response.json()
274+
275+
expect(response.status).toBe(401)
276+
expect(data).toHaveProperty('error', 'User not authenticated')
277+
expect(mockGetOAuthToken).not.toHaveBeenCalled()
278+
})
279+
280+
it('should reject internal JWT authentication', async () => {
281+
mockCheckHybridAuth.mockResolvedValueOnce({
282+
success: true,
283+
authType: 'internal_jwt',
284+
userId: 'test-user-id',
285+
})
286+
287+
const req = createMockRequest('POST', {
288+
credentialAccountUserId: 'test-user-id',
289+
providerId: 'google',
290+
})
291+
292+
const { POST } = await import('@/app/api/auth/oauth/token/route')
293+
294+
const response = await POST(req)
295+
const data = await response.json()
296+
297+
expect(response.status).toBe(401)
298+
expect(data).toHaveProperty('error', 'User not authenticated')
299+
expect(mockGetOAuthToken).not.toHaveBeenCalled()
300+
})
301+
302+
it('should reject requests for other users credentials', async () => {
303+
mockCheckHybridAuth.mockResolvedValueOnce({
304+
success: true,
305+
authType: 'session',
306+
userId: 'attacker-user-id',
307+
})
308+
309+
const req = createMockRequest('POST', {
310+
credentialAccountUserId: 'victim-user-id',
311+
providerId: 'google',
312+
})
313+
314+
const { POST } = await import('@/app/api/auth/oauth/token/route')
315+
316+
const response = await POST(req)
317+
const data = await response.json()
318+
319+
expect(response.status).toBe(403)
320+
expect(data).toHaveProperty('error', 'Unauthorized')
321+
expect(mockGetOAuthToken).not.toHaveBeenCalled()
322+
})
323+
324+
it('should allow session-authenticated users to access their own credentials', async () => {
325+
mockCheckHybridAuth.mockResolvedValueOnce({
326+
success: true,
327+
authType: 'session',
328+
userId: 'test-user-id',
329+
})
330+
mockGetOAuthToken.mockResolvedValueOnce('valid-access-token')
331+
332+
const req = createMockRequest('POST', {
333+
credentialAccountUserId: 'test-user-id',
334+
providerId: 'google',
335+
})
336+
337+
const { POST } = await import('@/app/api/auth/oauth/token/route')
338+
339+
const response = await POST(req)
340+
const data = await response.json()
341+
342+
expect(response.status).toBe(200)
343+
expect(data).toHaveProperty('accessToken', 'valid-access-token')
344+
expect(mockGetOAuthToken).toHaveBeenCalledWith('test-user-id', 'google')
345+
})
346+
347+
it('should return 404 when credential not found for user', async () => {
348+
mockCheckHybridAuth.mockResolvedValueOnce({
349+
success: true,
350+
authType: 'session',
351+
userId: 'test-user-id',
352+
})
353+
mockGetOAuthToken.mockResolvedValueOnce(null)
354+
355+
const req = createMockRequest('POST', {
356+
credentialAccountUserId: 'test-user-id',
357+
providerId: 'nonexistent-provider',
358+
})
359+
360+
const { POST } = await import('@/app/api/auth/oauth/token/route')
361+
362+
const response = await POST(req)
363+
const data = await response.json()
364+
365+
expect(response.status).toBe(404)
366+
expect(data.error).toContain('No credential found')
367+
})
368+
})
233369
})
234370

235371
/**

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ export async function POST(request: NextRequest) {
7171
providerId,
7272
})
7373

74+
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
75+
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
76+
logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
77+
success: auth.success,
78+
authType: auth.authType,
79+
})
80+
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
81+
}
82+
83+
if (auth.userId !== credentialAccountUserId) {
84+
logger.warn(
85+
`[${requestId}] User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}`
86+
)
87+
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
88+
}
89+
7490
try {
7591
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
7692
if (!accessToken) {

apps/sim/app/api/tools/asana/add-comment/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
45

56
export const dynamic = 'force-dynamic'
67

78
const logger = createLogger('AsanaAddCommentAPI')
89

9-
export async function POST(request: Request) {
10+
export async function POST(request: NextRequest) {
1011
try {
12+
const auth = await checkInternalAuth(request)
13+
if (!auth.success || !auth.userId) {
14+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
15+
}
16+
1117
const { accessToken, taskGid, text } = await request.json()
1218

1319
if (!accessToken) {

apps/sim/app/api/tools/asana/create-task/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
45

56
export const dynamic = 'force-dynamic'
67

78
const logger = createLogger('AsanaCreateTaskAPI')
89

9-
export async function POST(request: Request) {
10+
export async function POST(request: NextRequest) {
1011
try {
12+
const auth = await checkInternalAuth(request)
13+
if (!auth.success || !auth.userId) {
14+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
15+
}
16+
1117
const { accessToken, workspace, name, notes, assignee, due_on } = await request.json()
1218

1319
if (!accessToken) {

apps/sim/app/api/tools/asana/get-projects/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
45

56
export const dynamic = 'force-dynamic'
67

78
const logger = createLogger('AsanaGetProjectsAPI')
89

9-
export async function POST(request: Request) {
10+
export async function POST(request: NextRequest) {
1011
try {
12+
const auth = await checkInternalAuth(request)
13+
if (!auth.success || !auth.userId) {
14+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
15+
}
16+
1117
const { accessToken, workspace } = await request.json()
1218

1319
if (!accessToken) {

apps/sim/app/api/tools/asana/get-task/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
45

56
export const dynamic = 'force-dynamic'
67

78
const logger = createLogger('AsanaGetTaskAPI')
89

9-
export async function POST(request: Request) {
10+
export async function POST(request: NextRequest) {
1011
try {
12+
const auth = await checkInternalAuth(request)
13+
if (!auth.success || !auth.userId) {
14+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
15+
}
16+
1117
const { accessToken, taskGid, workspace, project, limit } = await request.json()
1218

1319
if (!accessToken) {

apps/sim/app/api/tools/asana/search-tasks/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
45

56
export const dynamic = 'force-dynamic'
67

78
const logger = createLogger('AsanaSearchTasksAPI')
89

9-
export async function POST(request: Request) {
10+
export async function POST(request: NextRequest) {
1011
try {
12+
const auth = await checkInternalAuth(request)
13+
if (!auth.success || !auth.userId) {
14+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
15+
}
16+
1117
const { accessToken, workspace, text, assignee, projects, completed } = await request.json()
1218

1319
if (!accessToken) {

apps/sim/app/api/tools/asana/update-task/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
45

56
export const dynamic = 'force-dynamic'
67

78
const logger = createLogger('AsanaUpdateTaskAPI')
89

9-
export async function PUT(request: Request) {
10+
export async function PUT(request: NextRequest) {
1011
try {
12+
const auth = await checkInternalAuth(request)
13+
if (!auth.success || !auth.userId) {
14+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
15+
}
16+
1117
const { accessToken, taskGid, name, notes, assignee, completed, due_on } = await request.json()
1218

1319
if (!accessToken) {

apps/sim/app/api/tools/confluence/attachment/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
45
import { getConfluenceCloudId } from '@/tools/confluence/utils'
56

@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceAttachmentAPI')
89
export const dynamic = 'force-dynamic'
910

1011
// Delete an attachment
11-
export async function DELETE(request: Request) {
12+
export async function DELETE(request: NextRequest) {
1213
try {
14+
const auth = await checkInternalAuth(request)
15+
if (!auth.success || !auth.userId) {
16+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
17+
}
18+
1319
const { domain, accessToken, cloudId: providedCloudId, attachmentId } = await request.json()
1420

1521
if (!domain) {

apps/sim/app/api/tools/confluence/attachments/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
2-
import { NextResponse } from 'next/server'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
34
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
45
import { getConfluenceCloudId } from '@/tools/confluence/utils'
56

@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceAttachmentsAPI')
89
export const dynamic = 'force-dynamic'
910

1011
// List attachments on a page
11-
export async function GET(request: Request) {
12+
export async function GET(request: NextRequest) {
1213
try {
14+
const auth = await checkInternalAuth(request)
15+
if (!auth.success || !auth.userId) {
16+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
17+
}
18+
1319
const { searchParams } = new URL(request.url)
1420
const domain = searchParams.get('domain')
1521
const accessToken = searchParams.get('accessToken')

0 commit comments

Comments
 (0)