Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,63 @@
import { createHmac } from 'crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { segmentUserSync } from '../user-sync'

describe('segmentUserSync.handleIdentify', () => {
const body = JSON.stringify({
type: 'identify',
userId: 'external-user-1',
traits: { email: 'user@example.com', plan: 'pro' },
})

it('rejects unsigned identify requests when no incoming secret is configured', async () => {
const request = new Request('https://example.com/api/integrations/segment/identify', {
method: 'POST',
})

const result = await segmentUserSync.handleIdentify?.(request, body, {}, {})

expect(result).toBeInstanceOf(Response)
expect((result as Response).status).toBe(401)
await expect((result as Response).text()).resolves.toBe(
'Segment incoming secret is not configured'
)
})

it('rejects identify requests when an incoming secret is configured but the signature is missing', async () => {
const request = new Request('https://example.com/api/integrations/segment/identify', {
method: 'POST',
})

const result = await segmentUserSync.handleIdentify?.(
request,
body,
{ incomingSecret: 'segment-secret' },
{}
)

expect(result).toBeInstanceOf(Response)
expect((result as Response).status).toBe(401)
await expect((result as Response).text()).resolves.toBe('Missing x-signature header')
})

it('accepts identify requests with a valid signature', async () => {
const incomingSecret = 'segment-secret'
const signature = createHmac('sha1', incomingSecret).update(body).digest('base64')
const request = new Request('https://example.com/api/integrations/segment/identify', {
method: 'POST',
headers: { 'x-signature': signature },
})

const result = await segmentUserSync.handleIdentify?.(request, body, { incomingSecret }, {})

expect(result).toEqual({
email: 'user@example.com',
externalUserId: 'external-user-1',
attributes: { email: 'user@example.com', plan: 'pro' },
})
})
Comment thread
BunsDev marked this conversation as resolved.
})

describe('segmentUserSync.syncSegmentMembership', () => {
beforeEach(() => {
vi.restoreAllMocks()
Expand Down
34 changes: 18 additions & 16 deletions apps/web/src/lib/server/integrations/segment/user-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,27 @@ const MAX_ERROR_BODY_LENGTH = 300

export const segmentUserSync: UserSyncHandler = {
async handleIdentify(request, body, config, _secrets): Promise<UserIdentifyPayload | Response> {
// Verify HMAC-SHA1 signature if a shared secret is configured.
// Segment signs the raw body with the source's shared secret.
const incomingSecret = config.incomingSecret as string | undefined
if (incomingSecret) {
const signature = request.headers.get('x-signature')
if (!signature) {
return new Response('Missing x-signature header', { status: 401 })
}
// Segment identify webhooks mutate user metadata, so require a shared secret
// and verify every inbound request before parsing the payload.
const incomingSecret = config.incomingSecret
if (typeof incomingSecret !== 'string' || incomingSecret.trim().length === 0) {
return new Response('Segment incoming secret is not configured', { status: 401 })
}

const expected = createHmac('sha1', incomingSecret).update(body).digest('base64')
try {
const sigBuf = Buffer.from(signature, 'base64')
const expBuf = Buffer.from(expected, 'base64')
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return new Response('Invalid signature', { status: 401 })
}
} catch {
const signature = request.headers.get('x-signature')
if (!signature) {
return new Response('Missing x-signature header', { status: 401 })
}

const expected = createHmac('sha1', incomingSecret).update(body).digest('base64')
try {
const sigBuf = Buffer.from(signature, 'base64')
const expBuf = Buffer.from(expected, 'base64')
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return new Response('Invalid signature', { status: 401 })
}
} catch {
return new Response('Invalid signature', { status: 401 })
}

let payload: Record<string, unknown>
Expand Down