Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/api/core/utils/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import User from '@api/core/models/User.model'
import httpStatus from 'http-status'
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { isNilUuid } from '@/utils/uuid'
import { withRetry } from './withRetry'
import * as Sentry from '@sentry/nextjs'

Expand All @@ -22,6 +23,10 @@ export const _authenticateWithToken = async (token: string, customApiKey?: strin
throw new APIError(httpStatus.UNAUTHORIZED, 'Failed to authenticate token')
}

if (isNilUuid(payload.data.internalUserId) || isNilUuid(payload.data.clientId)) {
throw new APIError(httpStatus.UNAUTHORIZED, 'Failed to authenticate token')
}

// Set sentry user for better tracking
Sentry.setUser({ id: payload.data.internalUserId || `${payload.data.clientId}:${payload.data.companyId}` })

Expand Down
18 changes: 18 additions & 0 deletions src/app/api/tests/utils/authenticate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ describe('authenticate util', () => {
}
})

it('throws APIError when token payload contains nil UUID user ids', async () => {
const { CopilotAPI } = jest.requireMock('@/utils/CopilotAPI') as {
CopilotAPI: jest.Mock
}
CopilotAPI.mockImplementationOnce(() => ({
getTokenPayload: jest.fn().mockResolvedValue({
internalUserId: '00000000-0000-0000-0000-000000000000',
workspaceId: 'workspace-id',
}),
}))

const req = buildNextRequest(`/?token=nil-uuid-token`)
await expect(authenticate(req)).rejects.toMatchObject({
status: httpStatus.UNAUTHORIZED,
message: 'Failed to authenticate token',
})
})

it('captures assembly metadata headers when present', async () => {
const req = new NextRequest(
new Request(process.env.VERCEL_URL + '/?token=iu-token', {
Expand Down
61 changes: 61 additions & 0 deletions src/utils/CopilotAPI.nil-uuid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import APIError from '@/app/api/core/exceptions/api'
import httpStatus from 'http-status'
import { NIL_UUID } from '@/utils/uuid'

const mockRetrieveInternalUser = jest.fn()
const mockRetrieveClient = jest.fn()

jest.mock('@/app/api/core/utils/withRetry', () => ({
withRetry: async (fn: (...args: unknown[]) => Promise<unknown>, args: unknown[]) => fn(...args),
RETRY_404_ENABLED: false,
}))

jest.mock('copilot-node-sdk', () => ({
copilotApi: () => ({
retrieveInternalUser: mockRetrieveInternalUser,
retrieveClient: mockRetrieveClient,
getTokenPayload: jest.fn(),
}),
}))

import { CopilotAPI } from '@/utils/CopilotAPI'

describe('CopilotAPI nil UUID guards', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('rejects nil UUID for getInternalUser without calling Copilot', async () => {
const copilot = new CopilotAPI('token')

await expect(copilot.getInternalUser(NIL_UUID)).rejects.toBeInstanceOf(APIError)
await expect(copilot.getInternalUser(NIL_UUID)).rejects.toMatchObject({
status: httpStatus.BAD_REQUEST,
message: 'Invalid internal user id',
})
expect(mockRetrieveInternalUser).not.toHaveBeenCalled()
})

it('rejects nil UUID for getClient without calling Copilot', async () => {
const copilot = new CopilotAPI('token')

await expect(copilot.getClient(NIL_UUID)).rejects.toBeInstanceOf(APIError)
await expect(copilot.getClient(NIL_UUID)).rejects.toMatchObject({
status: httpStatus.BAD_REQUEST,
message: 'Invalid client id',
})
expect(mockRetrieveClient).not.toHaveBeenCalled()
})

it('returns null from me when token id is nil UUID', async () => {
const copilot = new CopilotAPI('token')
jest.spyOn(copilot, 'getTokenPayload').mockResolvedValue({
internalUserId: NIL_UUID,
workspaceId: 'workspace-id',
})

await expect(copilot.me()).resolves.toBeNull()
expect(mockRetrieveInternalUser).not.toHaveBeenCalled()
expect(mockRetrieveClient).not.toHaveBeenCalled()
})
})
9 changes: 8 additions & 1 deletion src/utils/CopilotAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { DISPATCHABLE_EVENT } from '@/types/webhook'
import Bottleneck from 'bottleneck'
import type { CopilotAPI as SDK } from 'copilot-node-sdk'
import { copilotApi } from 'copilot-node-sdk'
import { isNilUuid } from '@/utils/uuid'
import { cache } from 'react'
import { z } from 'zod'

Expand Down Expand Up @@ -116,7 +117,7 @@ export class CopilotAPI {
console.info('CopilotAPI#_me', this.token)
const tokenPayload = await this.getTokenPayload()
const id = tokenPayload?.internalUserId || tokenPayload?.clientId
if (!tokenPayload || !id) return null
if (!tokenPayload || !id || isNilUuid(id)) return null

const retrieveCurrentUserInfo = tokenPayload.internalUserId
? this.copilot.retrieveInternalUser
Expand Down Expand Up @@ -153,6 +154,9 @@ export class CopilotAPI {
}

async _getClient(id: string): Promise<ClientResponse> {
if (isNilUuid(id)) {
throw new APIError(httpStatus.BAD_REQUEST, 'Invalid client id')
}
console.info('CopilotAPI#_getClient', this.token)
return ClientResponseSchema.parse(await this.copilot.retrieveClient({ id }))
}
Expand Down Expand Up @@ -228,6 +232,9 @@ export class CopilotAPI {
}

async _getInternalUser(id: string): Promise<InternalUsers> {
if (isNilUuid(id)) {
throw new APIError(httpStatus.BAD_REQUEST, 'Invalid internal user id')
}
console.info('CopilotAPI#_getInternalUser', this.token)
return InternalUsersSchema.parse(await this.copilot.retrieveInternalUser({ id }))
}
Expand Down
14 changes: 14 additions & 0 deletions src/utils/uuid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NIL_UUID, isNilUuid } from '@/utils/uuid'

describe('uuid utils', () => {
it('identifies the nil UUID', () => {
expect(isNilUuid(NIL_UUID)).toBe(true)
})

it('rejects real and empty ids', () => {
expect(isNilUuid('591aaab2-f128-419e-8f40-65fb83e71a5e')).toBe(false)
expect(isNilUuid('')).toBe(false)
expect(isNilUuid(null)).toBe(false)
expect(isNilUuid(undefined)).toBe(false)
})
})
3 changes: 3 additions & 0 deletions src/utils/uuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const NIL_UUID = '00000000-0000-0000-0000-000000000000' as const

export const isNilUuid = (id: string | null | undefined): id is typeof NIL_UUID => id === NIL_UUID
Loading