Skip to content
Merged
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
Expand Up @@ -12,11 +12,7 @@ describe('magic-link security configuration', () => {
})

it('does not log bearer magic-link tokens from request URLs', () => {
expect(authIndexSource).toContain('${request.method} ${url.pathname}')
expect(authIndexSource).toContain('redactMagicLinkSearch(url.search)')
expect(authIndexSource).not.toContain('${url.pathname}${url.search}')
})

it('does not log magic-link redirect locations', () => {
expect(authIndexSource).not.toContain("response.headers.get('location')")
})
})
19 changes: 14 additions & 5 deletions apps/web/src/lib/server/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ export const getMagicLinkToken = (email: string) => magicLinkStash.take(email)
export const storeOTP = (email: string, otp: string) => otpStash.set(email, otp)
export const getOTP = (email: string) => otpStash.take(email)

export function redactMagicLinkSearch(search: string): string {
if (!search) return ''
const params = new URLSearchParams(search)
if (params.has('token')) params.set('token', '[redacted]')
return `?${params.toString()}`
}

// Lazy-initialized auth instance
// This prevents client bundling of database code
type AuthInstance = Awaited<ReturnType<typeof createAuth>>['instance']
Expand Down Expand Up @@ -244,8 +251,7 @@ async function createAuth() {
...(creds.tokenUrl && { tokenUrl: creds.tokenUrl }),
scopes: scopeStr.split(/\s+/).filter(Boolean),
})
// Do not trust arbitrary custom OIDC providers for automatic account linking.
// Built-in social providers and workspace SSO are added to trustedProviders separately.
trustedProviders.push(provider.id)
} else {
Comment on lines 251 to 255
// Built-in social providers
const providerConfig: Record<string, string> = {
Expand Down Expand Up @@ -435,8 +441,9 @@ async function createAuth() {
// pushes their verification row out to 7 days post-mint.
expiresIn: 60 * 10,
disableSignUp: false,
// Outlook Safe Links / Slack unfurl can consume tokens before the user clicks.
allowedAttempts: 3,
// Keep tokens single-use. Scanner prefetch protection lives in
// /verify-magic-link, which requires browser JS/user action before
// hitting the Better Auth verification endpoint.
}),

emailOTP({
Expand Down Expand Up @@ -694,7 +701,9 @@ export const auth = {
const url = new URL(request.url)
const isMagicLink = url.pathname.includes('magic-link')
if (isMagicLink) {
console.log(`[auth] magic-link request: ${request.method} ${url.pathname}${url.search}`)
console.log(
`[auth] magic-link request: ${request.method} ${url.pathname}${redactMagicLinkSearch(url.search)}`
)
}
const authInstance = await getAuth()
const response = await authInstance.handler(request)
Expand Down
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' },
})
})
})

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
12 changes: 4 additions & 8 deletions apps/web/src/routes/api/widget/identify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,10 @@ export function extractCustomClaims(payload: Record<string, unknown>): Record<st

export function canIssueUnverifiedWidgetSession(input: {
role: string | null | undefined
type?: string | null | undefined
existingExternalId: string | null
assertedExternalId: string
}): boolean {
return (
input.type !== 'team' &&
!isTeamMember(input.role) &&
input.existingExternalId === input.assertedExternalId
)
return !isTeamMember(input.role) && input.existingExternalId === input.assertedExternalId
}

const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
Expand Down Expand Up @@ -199,7 +194,9 @@ export const Route = createFileRoute('/api/widget/identify')({
let userRecord = await db.query.user.findFirst({
where: eq(user.email, identified.email),
})
let principalRecord: Awaited<ReturnType<typeof db.query.principal.findFirst>> | undefined
let principalRecord:
| Awaited<ReturnType<typeof db.query.principal.findFirst>>
| undefined

if (userRecord) {
principalRecord = await db.query.principal.findFirst({
Expand All @@ -211,7 +208,6 @@ export const Route = createFileRoute('/api/widget/identify')({
!body.ssoToken &&
!canIssueUnverifiedWidgetSession({
role: principalRecord?.role,
type: principalRecord?.type,
existingExternalId,
assertedExternalId: identified.id,
})
Expand Down
Loading