diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx index 8de9b75a2..6412b1959 100644 --- a/src/app/client/page.tsx +++ b/src/app/client/page.tsx @@ -13,12 +13,12 @@ import { SilentError } from '@/components/templates/SilentError' import { apiUrl } from '@/config' import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate' import { RealTime } from '@/hoc/RealTime' -import { Token, TokenSchema, UrlActionParamsType } from '@/types/common' +import { UrlActionParamsType } from '@/types/common' import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' import { TaskResponse } from '@/types/dto/tasks.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { UserType } from '@/types/interfaces' -import { CopilotAPI } from '@/utils/CopilotAPI' +import { getSafeTokenPayload } from '@/utils/tokenPayload' import { getPreviewMode } from '@/utils/previewMode' import { redirectIfTaskCta } from '@/utils/redirect' import { UserRole } from '@api/core/types/user' @@ -43,12 +43,6 @@ async function getAllTasks(token: string): Promise { return data.tasks } -async function getTokenPayload(token: string): Promise { - const copilotClient = new CopilotAPI(token) - const payload = TokenSchema.parse(await copilotClient.getTokenPayload()) - return payload as Token -} - export default async function ClientPage(props: { searchParams: Promise<{ token: string } & UrlActionParamsType> }) { const searchParams = await props.searchParams const token = searchParams.token @@ -56,11 +50,16 @@ export default async function ClientPage(props: { searchParams: Promise<{ token: return } redirectIfTaskCta(searchParams, UserType.CLIENT_USER) - const [workflowStates, tasks, viewSettings, tokenPayload] = await Promise.all([ + + const tokenPayload = await getSafeTokenPayload({ token }) + if (!tokenPayload) { + return + } + + const [workflowStates, tasks, viewSettings] = await Promise.all([ getAllWorkflowStates(token), getAllTasks(token), getViewSettings(token), - getTokenPayload(token), ]) const previewMode = getPreviewMode(tokenPayload) diff --git a/src/utils/tokenPayload.test.ts b/src/utils/tokenPayload.test.ts new file mode 100644 index 000000000..787dea3ef --- /dev/null +++ b/src/utils/tokenPayload.test.ts @@ -0,0 +1,56 @@ +import { mockTokenPayloads } from '@/app/api/tests/__mocks__/mockData' +import { CopilotAPI } from '@/utils/CopilotAPI' +import { getSafeTokenPayload } from '@/utils/tokenPayload' + +const mockGetTokenPayload = jest.fn() +const launchToken = 'a'.repeat(64) + +jest.mock('@/utils/CopilotAPI', () => ({ + CopilotAPI: jest.fn().mockImplementation(() => ({ + getTokenPayload: mockGetTokenPayload, + })), +})) + +describe('getSafeTokenPayload', () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetTokenPayload.mockReset() + }) + + it('returns a valid token payload', async () => { + mockGetTokenPayload.mockResolvedValueOnce(mockTokenPayloads.client) + + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toEqual(mockTokenPayloads.client) + expect(CopilotAPI).toHaveBeenCalledWith(launchToken) + }) + + it('returns null without constructing the SDK for impossible token strings', async () => { + await expect(getSafeTokenPayload({ token: 'invalid-token' })).resolves.toBeNull() + expect(CopilotAPI).not.toHaveBeenCalled() + }) + + it('returns null when Copilot cannot authorize the token', async () => { + mockGetTokenPayload.mockRejectedValueOnce(new Error('Unable to authorize Copilot SDK')) + + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toBeNull() + }) + + it('returns null when Copilot returns an auth failure status', async () => { + mockGetTokenPayload.mockRejectedValueOnce({ status: 401 }) + + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toBeNull() + }) + + it('returns null for malformed token payloads', async () => { + mockGetTokenPayload.mockResolvedValueOnce({ workspaceId: 123 }) + + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toBeNull() + }) + + it('throws unexpected errors', async () => { + const error = new Error('network unavailable') + mockGetTokenPayload.mockRejectedValueOnce(error) + + await expect(getSafeTokenPayload({ token: launchToken })).rejects.toThrow(error) + }) +}) diff --git a/src/utils/tokenPayload.ts b/src/utils/tokenPayload.ts new file mode 100644 index 000000000..bc49f7a8a --- /dev/null +++ b/src/utils/tokenPayload.ts @@ -0,0 +1,42 @@ +import { Token, TokenSchema } from '@/types/common' +import { CopilotAPI } from '@/utils/CopilotAPI' +import { ZodError } from 'zod' + +type GetSafeTokenPayloadArgs = { + token: string +} + +const COPILOT_TOKEN_PATTERN = /^[0-9a-f]+$/i + +const isPotentialCopilotLaunchToken = (token: string): boolean => + token.length >= 64 && token.length % 32 === 0 && COPILOT_TOKEN_PATTERN.test(token) + +const getErrorStatus = (error: unknown): number | null => { + if (!error || typeof error !== 'object') return null + + const status = 'status' in error ? error.status : undefined + if (typeof status === 'number') return status + + const statusCode = 'statusCode' in error ? error.statusCode : undefined + return typeof statusCode === 'number' ? statusCode : null +} + +const isTokenAuthorizationError = (error: unknown): boolean => { + const status = getErrorStatus(error) + if (status && [401, 403].includes(status)) return true + + return error instanceof ZodError || (error instanceof Error && error.message.includes('Unable to authorize Copilot SDK')) +} + +export async function getSafeTokenPayload({ token }: GetSafeTokenPayloadArgs): Promise { + if (!isPotentialCopilotLaunchToken(token)) return null + + try { + const payload = await new CopilotAPI(token).getTokenPayload() + const parsedPayload = TokenSchema.safeParse(payload) + return parsedPayload.success ? parsedPayload.data : null + } catch (error: unknown) { + if (isTokenAuthorizationError(error)) return null + throw error + } +}