diff --git a/apps/web/src/app/api/integrations/google-calendar/webhook/__tests__/route.test.ts b/apps/web/src/app/api/integrations/google-calendar/webhook/__tests__/route.test.ts new file mode 100644 index 000000000..d499dab9d --- /dev/null +++ b/apps/web/src/app/api/integrations/google-calendar/webhook/__tests__/route.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { POST } from '../route'; +import { generateWebhookToken } from '@/lib/integrations/google-calendar/webhook-token'; +import { _resetWarningFlag } from '@/lib/integrations/google-calendar/webhook-auth'; + +// Mock the sync service +vi.mock('@/lib/integrations/google-calendar/sync-service', () => ({ + syncGoogleCalendar: vi.fn().mockResolvedValue(undefined), +})); + +// Mock loggers +vi.mock('@pagespace/lib/server', () => ({ + loggers: { + api: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }, +})); + +// Mock next/server after() - it executes the callback synchronously in tests +vi.mock('next/server', async () => { + const actual = await vi.importActual('next/server'); + return { + ...actual, + after: (fn: () => void) => fn(), + }; +}); + +describe('Google Calendar Webhook Route', () => { + const originalEnv = process.env; + const TEST_SECRET = 'test-oauth-state-secret'; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.OAUTH_STATE_SECRET = TEST_SECRET; + _resetWarningFlag(); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + function createWebhookRequest(overrides: { + channelId?: string | null; + resourceId?: string | null; + resourceState?: string; + channelToken?: string | null; + } = {}): Request { + const headers: Record = {}; + + if (overrides.channelId !== null) { + headers['X-Goog-Channel-ID'] = overrides.channelId ?? 'channel-123'; + } + if (overrides.resourceId !== null) { + headers['X-Goog-Resource-ID'] = overrides.resourceId ?? 'resource-456'; + } + if (overrides.resourceState) { + headers['X-Goog-Resource-State'] = overrides.resourceState; + } + if (overrides.channelToken !== null && overrides.channelToken !== undefined) { + headers['X-Goog-Channel-Token'] = overrides.channelToken; + } + + return new Request('http://localhost:3000/api/integrations/google-calendar/webhook', { + method: 'POST', + headers, + }); + } + + describe('header validation', () => { + it('given missing channel ID, should return 400', async () => { + const request = createWebhookRequest({ + channelId: null, + resourceId: 'resource-123', + resourceState: 'exists', + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Missing headers'); + }); + + it('given missing resource ID, should return 400', async () => { + const request = createWebhookRequest({ + channelId: 'channel-123', + resourceId: null, + resourceState: 'exists', + }); + + const response = await POST(request); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe('Missing headers'); + }); + }); + + describe('sync confirmation (resourceState=sync)', () => { + it('given sync state, should return 200 without auth check', async () => { + const request = createWebhookRequest({ + resourceState: 'sync', + channelToken: null, // No token provided + }); + + const response = await POST(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.ok).toBe(true); + }); + }); + + describe('zero-trust authentication', () => { + it('given valid token, should return 200 and trigger sync', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + const userId = 'user-123'; + const token = generateWebhookToken(userId); + + const request = createWebhookRequest({ + resourceState: 'exists', + channelToken: token, + }); + + const response = await POST(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.ok).toBe(true); + expect(syncGoogleCalendar).toHaveBeenCalledWith(userId); + }); + + it('given missing token, should return 401 and NOT trigger sync', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + + const request = createWebhookRequest({ + resourceState: 'exists', + channelToken: null, + }); + + const response = await POST(request); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.error).toBe('Missing authentication token'); + expect(syncGoogleCalendar).not.toHaveBeenCalled(); + }); + + it('given invalid token, should return 401 and NOT trigger sync', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + + const request = createWebhookRequest({ + resourceState: 'exists', + channelToken: 'invalid.token', + }); + + const response = await POST(request); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.error).toBe('Invalid authentication token'); + expect(syncGoogleCalendar).not.toHaveBeenCalled(); + }); + + it('given tampered token, should return 401 and NOT trigger sync', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + const validToken = generateWebhookToken('user-123'); + const tamperedToken = validToken.slice(0, -8) + 'deadbeef'; + + const request = createWebhookRequest({ + resourceState: 'exists', + channelToken: tamperedToken, + }); + + const response = await POST(request); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.error).toBe('Invalid authentication token'); + expect(syncGoogleCalendar).not.toHaveBeenCalled(); + }); + }); + + describe('no fallback paths (critical security tests)', () => { + it('given valid channel/resource IDs but no token, should NOT trigger sync', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + + const request = createWebhookRequest({ + channelId: 'valid-channel-id', + resourceId: 'valid-resource-id', + resourceState: 'exists', + channelToken: null, + }); + + const response = await POST(request); + + expect(response.status).toBe(401); + expect(syncGoogleCalendar).not.toHaveBeenCalled(); + }); + + it('given valid channel/resource IDs with invalid token, should NOT trigger sync', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + + const request = createWebhookRequest({ + channelId: 'valid-channel-id', + resourceId: 'valid-resource-id', + resourceState: 'exists', + channelToken: 'invalid.signature', + }); + + const response = await POST(request); + + expect(response.status).toBe(401); + expect(syncGoogleCalendar).not.toHaveBeenCalled(); + }); + + it('given matching channel/resource IDs with token for different user, should sync with token user only', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + const tokenUserId = 'token-user-456'; + const token = generateWebhookToken(tokenUserId); + + const request = createWebhookRequest({ + channelId: 'channel-for-different-user', + resourceId: 'resource-for-different-user', + resourceState: 'exists', + channelToken: token, + }); + + const response = await POST(request); + + expect(response.status).toBe(200); + // Sync is triggered for the user encoded in the token, not based on channel lookup + expect(syncGoogleCalendar).toHaveBeenCalledWith(tokenUserId); + }); + }); + + describe('fail-closed behavior', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + (process.env as Record).NODE_ENV = originalNodeEnv; + }); + + it('given production without OAUTH_STATE_SECRET, should return 500', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + delete process.env.OAUTH_STATE_SECRET; + (process.env as Record).NODE_ENV = 'production'; + + const request = createWebhookRequest({ + resourceState: 'exists', + channelToken: 'any-token', + }); + + const response = await POST(request); + + expect(response.status).toBe(500); + expect(syncGoogleCalendar).not.toHaveBeenCalled(); + }); + + it('given development without OAUTH_STATE_SECRET, should return 401', async () => { + const { syncGoogleCalendar } = await import('@/lib/integrations/google-calendar/sync-service'); + delete process.env.OAUTH_STATE_SECRET; + (process.env as Record).NODE_ENV = 'development'; + + const request = createWebhookRequest({ + resourceState: 'exists', + channelToken: 'any-token', + }); + + const response = await POST(request); + + expect(response.status).toBe(401); + expect(syncGoogleCalendar).not.toHaveBeenCalled(); + }); + }); + + describe('logging', () => { + it('given auth failure, should log with channel/resource IDs', async () => { + const { loggers } = await import('@pagespace/lib/server'); + + const request = createWebhookRequest({ + channelId: 'log-test-channel', + resourceId: 'log-test-resource', + resourceState: 'exists', + channelToken: null, + }); + + await POST(request); + + expect(loggers.api.warn).toHaveBeenCalledWith( + 'Google Calendar webhook: auth failed', + expect.objectContaining({ + channelId: 'log-test-channel', + resourceId: 'log-test-resource', + hasToken: false, + }) + ); + }); + + it('given successful auth, should log sync trigger with userId', async () => { + const { loggers } = await import('@pagespace/lib/server'); + const userId = 'log-user-123'; + const token = generateWebhookToken(userId); + + const request = createWebhookRequest({ + channelId: 'log-channel', + resourceState: 'exists', + channelToken: token, + }); + + await POST(request); + + expect(loggers.api.info).toHaveBeenCalledWith( + 'Google Calendar webhook: triggering sync', + expect.objectContaining({ + userId, + channelId: 'log-channel', + resourceState: 'exists', + }) + ); + }); + }); +}); diff --git a/apps/web/src/app/api/integrations/google-calendar/webhook/route.ts b/apps/web/src/app/api/integrations/google-calendar/webhook/route.ts index 3f9cb48f8..12955f416 100644 --- a/apps/web/src/app/api/integrations/google-calendar/webhook/route.ts +++ b/apps/web/src/app/api/integrations/google-calendar/webhook/route.ts @@ -1,11 +1,7 @@ import { NextResponse, after } from 'next/server'; -import { db, googleCalendarConnections, eq } from '@pagespace/db'; import { loggers } from '@pagespace/lib/server'; import { syncGoogleCalendar } from '@/lib/integrations/google-calendar/sync-service'; -import { verifyWebhookToken } from '@/lib/integrations/google-calendar/webhook-token'; - -type WebhookChannel = { channelId: string; resourceId: string; expiration: string }; -type WebhookChannels = Record; +import { validateWebhookAuth } from '@/lib/integrations/google-calendar/webhook-auth'; /** * POST /api/integrations/google-calendar/webhook @@ -20,8 +16,9 @@ type WebhookChannels = Record; * - X-Goog-Message-Number: Sequential message number * - X-Goog-Channel-Token: Secret token we provided during watch registration * - * We validate the channel token (HMAC) first, then match the channelId - * against our stored webhook channels as a secondary check. + * Zero-trust authentication: + * - All sync-triggering requests MUST include a valid HMAC token + * - No fallback to channel/resource ID lookup */ export async function POST(request: Request) { try { @@ -35,56 +32,32 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Missing headers' }, { status: 400 }); } - // Initial sync notification - just acknowledge + // Initial sync notification - just acknowledge (no auth required for sync confirmation) if (resourceState === 'sync') { - loggers.api.info('Google Calendar webhook: sync confirmation received', { channelId }); + loggers.api.info('Google Calendar webhook: sync confirmation received', { channelId, hasToken: !!channelToken }); return NextResponse.json({ ok: true }); } - // Primary auth: validate HMAC token if present - const tokenUserId = channelToken ? verifyWebhookToken(channelToken) : null; - - let matchedUserId: string | null = tokenUserId; - - // If token auth didn't resolve a userId, fall back to channel lookup - if (!matchedUserId) { - const connections = await db.query.googleCalendarConnections.findMany({ - where: eq(googleCalendarConnections.status, 'active'), - columns: { - userId: true, - webhookChannels: true, - }, + // Zero-trust authentication: require valid HMAC token + const authResult = validateWebhookAuth(channelToken); + if (authResult instanceof NextResponse) { + loggers.api.warn('Google Calendar webhook: auth failed', { + channelId, + resourceId, + hasToken: !!channelToken, }); - - for (const conn of connections) { - const channels = conn.webhookChannels; - if (!channels || typeof channels !== 'object') continue; - const typedChannels = channels as WebhookChannels; - for (const calId of Object.keys(typedChannels)) { - const ch = typedChannels[calId]; - if (ch?.channelId === channelId && ch?.resourceId === resourceId) { - matchedUserId = conn.userId; - break; - } - } - if (matchedUserId) break; - } + return authResult; } - if (!matchedUserId) { - loggers.api.warn('Google Calendar webhook: no matching connection found', { channelId, resourceId }); - // Return 200 to prevent Google from retrying - return NextResponse.json({ ok: true }); - } + const { userId } = authResult; loggers.api.info('Google Calendar webhook: triggering sync', { - userId: matchedUserId, + userId, channelId, resourceState, }); // Use after() to ensure sync runs to completion even after response is sent - const userId = matchedUserId; after(() => { syncGoogleCalendar(userId).catch((error) => { loggers.api.error('Google Calendar webhook sync failed', error as Error, { @@ -96,7 +69,7 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true }); } catch (error) { loggers.api.error('Google Calendar webhook error:', error as Error); - // Always return 200 to prevent Google from retrying on server errors - return NextResponse.json({ ok: true }); + // Return 500 for unexpected errors (don't mask server issues) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/apps/web/src/lib/integrations/google-calendar/__tests__/webhook-auth.test.ts b/apps/web/src/lib/integrations/google-calendar/__tests__/webhook-auth.test.ts new file mode 100644 index 000000000..04f584039 --- /dev/null +++ b/apps/web/src/lib/integrations/google-calendar/__tests__/webhook-auth.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { validateWebhookAuth, _resetWarningFlag } from '../webhook-auth'; +import { generateWebhookToken } from '../webhook-token'; + +describe('webhook-auth', () => { + const originalEnv = process.env; + const TEST_SECRET = 'test-oauth-state-secret'; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.OAUTH_STATE_SECRET = TEST_SECRET; + _resetWarningFlag(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('validateWebhookAuth', () => { + describe('with valid configuration', () => { + it('given valid token, should return userId', () => { + const userId = 'user-123'; + const token = generateWebhookToken(userId); + + const result = validateWebhookAuth(token); + + expect(result).toEqual({ userId }); + }); + + it('given missing token (null), should return 401', async () => { + const result = validateWebhookAuth(null); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + expect(data.error).toBe('Missing authentication token'); + }); + + it('given empty token, should return 401 as missing', async () => { + const result = validateWebhookAuth(''); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + // Empty string is treated as missing (falsy value) + expect(data.error).toBe('Missing authentication token'); + }); + + it('given malformed token (no dot separator), should return 401', async () => { + const result = validateWebhookAuth('malformed-token-no-dot'); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + expect(data.error).toBe('Invalid authentication token'); + }); + + it('given tampered token signature, should return 401', async () => { + const userId = 'user-456'; + const token = generateWebhookToken(userId); + const tamperedToken = token.slice(0, -8) + 'deadbeef'; + + const result = validateWebhookAuth(tamperedToken); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + expect(data.error).toBe('Invalid authentication token'); + }); + + it('given token with modified userId, should return 401', async () => { + const originalToken = generateWebhookToken('user-original'); + const [, signature] = originalToken.split('.'); + const tamperedToken = `user-attacker.${signature}`; + + const result = validateWebhookAuth(tamperedToken); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + expect(data.error).toBe('Invalid authentication token'); + }); + + it('given token signed with different secret, should return 401', async () => { + // Generate token with current secret + const token = generateWebhookToken('user-789'); + + // Change secret and try to verify + process.env.OAUTH_STATE_SECRET = 'different-secret'; + + const result = validateWebhookAuth(token); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + expect(data.error).toBe('Invalid authentication token'); + }); + }); + + describe('fail-closed behavior without OAUTH_STATE_SECRET', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + delete process.env.OAUTH_STATE_SECRET; + _resetWarningFlag(); + }); + + afterEach(() => { + (process.env as Record).NODE_ENV = originalNodeEnv; + }); + + it('given production mode without secret, should return 500', async () => { + (process.env as Record).NODE_ENV = 'production'; + + const result = validateWebhookAuth('any-token'); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(500); + + const data = await (result as Response).json(); + expect(data.error).toContain('not configured'); + }); + + it('given development mode without secret, should return 401', async () => { + (process.env as Record).NODE_ENV = 'development'; + + const result = validateWebhookAuth('any-token'); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + expect(data.error).toBe('Missing authentication token'); + }); + + it('given test mode without secret, should return 401', async () => { + (process.env as Record).NODE_ENV = 'test'; + + const result = validateWebhookAuth('any-token'); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + + const data = await (result as Response).json(); + expect(data.error).toBe('Missing authentication token'); + }); + + it('should only log warning once in non-production', () => { + (process.env as Record).NODE_ENV = 'development'; + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + validateWebhookAuth('token-1'); + validateWebhookAuth('token-2'); + validateWebhookAuth('token-3'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('OAUTH_STATE_SECRET is not configured') + ); + + consoleSpy.mockRestore(); + }); + }); + }); +}); diff --git a/apps/web/src/lib/integrations/google-calendar/__tests__/webhook-token.test.ts b/apps/web/src/lib/integrations/google-calendar/__tests__/webhook-token.test.ts new file mode 100644 index 000000000..19b6f8656 --- /dev/null +++ b/apps/web/src/lib/integrations/google-calendar/__tests__/webhook-token.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { generateWebhookToken, verifyWebhookToken } from '../webhook-token'; + +describe('webhook-token', () => { + const originalEnv = process.env; + const TEST_SECRET = 'test-oauth-state-secret'; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.OAUTH_STATE_SECRET = TEST_SECRET; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('generateWebhookToken', () => { + it('given userId, should generate valid token', () => { + const userId = 'user-123'; + const token = generateWebhookToken(userId); + + expect(token).toMatch(/^user-123\.[a-f0-9]{64}$/); + }); + + it('given same userId, should generate same token', () => { + const userId = 'user-123'; + const token1 = generateWebhookToken(userId); + const token2 = generateWebhookToken(userId); + + expect(token1).toBe(token2); + }); + + it('given different userIds, should generate different tokens', () => { + const token1 = generateWebhookToken('user-1'); + const token2 = generateWebhookToken('user-2'); + + expect(token1).not.toBe(token2); + }); + + it('given userId containing a dot, should throw', () => { + expect(() => generateWebhookToken('user.with.dots')).toThrow( + 'userId must not contain dots' + ); + }); + + describe('fail-closed behavior', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + (process.env as Record).NODE_ENV = originalNodeEnv; + }); + + it('given production without OAUTH_STATE_SECRET, should throw', () => { + delete process.env.OAUTH_STATE_SECRET; + (process.env as Record).NODE_ENV = 'production'; + + expect(() => generateWebhookToken('user-123')).toThrow( + 'OAUTH_STATE_SECRET must be configured in production' + ); + }); + + it('given development without OAUTH_STATE_SECRET, should return empty string', () => { + delete process.env.OAUTH_STATE_SECRET; + (process.env as Record).NODE_ENV = 'development'; + + const token = generateWebhookToken('user-123'); + expect(token).toBe(''); + }); + + it('given test mode without OAUTH_STATE_SECRET, should return empty string', () => { + delete process.env.OAUTH_STATE_SECRET; + (process.env as Record).NODE_ENV = 'test'; + + const token = generateWebhookToken('user-123'); + expect(token).toBe(''); + }); + }); + }); + + describe('verifyWebhookToken', () => { + it('given valid token, should return userId', () => { + const userId = 'user-456'; + const token = generateWebhookToken(userId); + + const result = verifyWebhookToken(token); + + expect(result).toBe(userId); + }); + + it('given empty token, should return null', () => { + expect(verifyWebhookToken('')).toBeNull(); + }); + + it('given malformed token (no dot), should return null', () => { + expect(verifyWebhookToken('no-dot-separator')).toBeNull(); + }); + + it('given tampered signature, should return null', () => { + const token = generateWebhookToken('user-123'); + const tampered = token.slice(0, -8) + 'deadbeef'; + + expect(verifyWebhookToken(tampered)).toBeNull(); + }); + + it('given modified userId, should return null', () => { + const token = generateWebhookToken('user-original'); + const [, signature] = token.split('.'); + const tampered = `user-attacker.${signature}`; + + expect(verifyWebhookToken(tampered)).toBeNull(); + }); + + it('given wrong signature length, should return null', () => { + expect(verifyWebhookToken('user-123.short')).toBeNull(); + }); + + it('given non-hex signature, should return null', () => { + expect(verifyWebhookToken('user-123.zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz')).toBeNull(); + }); + + it('given token without secret configured, should return null', () => { + const token = generateWebhookToken('user-123'); + delete process.env.OAUTH_STATE_SECRET; + + expect(verifyWebhookToken(token)).toBeNull(); + }); + }); + + describe('round-trip', () => { + it('should verify tokens it generates', () => { + const userId = 'round-trip-user'; + const token = generateWebhookToken(userId); + const verified = verifyWebhookToken(token); + + expect(verified).toBe(userId); + }); + + it('should handle UUID userIds', () => { + // UserIds are UUIDs in practice (no dots allowed since token uses dot separator) + const userId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const token = generateWebhookToken(userId); + const verified = verifyWebhookToken(token); + + expect(verified).toBe(userId); + }); + }); +}); diff --git a/apps/web/src/lib/integrations/google-calendar/webhook-auth.ts b/apps/web/src/lib/integrations/google-calendar/webhook-auth.ts new file mode 100644 index 000000000..74d1cda30 --- /dev/null +++ b/apps/web/src/lib/integrations/google-calendar/webhook-auth.ts @@ -0,0 +1,83 @@ +/** + * Zero-Trust Webhook Authentication + * + * Validates Google Calendar webhook requests using cryptographic HMAC tokens. + * This module enforces strict authentication with no fallback paths. + * + * Security model: + * 1. All webhook requests MUST include a valid HMAC token + * 2. Fail-closed in production: Missing OAUTH_STATE_SECRET returns 500 + * 3. No fallback to channel/resource ID lookup (zero-trust) + */ + +import { NextResponse } from 'next/server'; +import { verifyWebhookToken } from './webhook-token'; + +export type WebhookAuthResult = { userId: string }; + +let secretWarningLogged = false; + +/** + * Reset the warning flag (exported for testing only) + */ +export function _resetWarningFlag(): void { + secretWarningLogged = false; +} + +/** + * Validate webhook authentication token. + * + * Returns: + * - { userId: string } on successful authentication + * - NextResponse on failure (return immediately to client) + * + * Failure modes: + * - 500: Production without OAUTH_STATE_SECRET (fail-closed) + * - 401: Missing authentication token + * - 401: Invalid authentication token + */ +export function validateWebhookAuth( + channelToken: string | null +): WebhookAuthResult | NextResponse { + const secret = process.env.OAUTH_STATE_SECRET; + + // Fail-closed in production: OAUTH_STATE_SECRET must be configured + if (!secret) { + if (process.env.NODE_ENV === 'production') { + return NextResponse.json( + { error: 'Internal server error - webhook authentication not configured' }, + { status: 500 } + ); + } + // Development/test: warn once and return 401 + if (!secretWarningLogged) { + console.warn( + '[webhook-auth] OAUTH_STATE_SECRET is not configured. Webhook authentication will fail.' + ); + secretWarningLogged = true; + } + return NextResponse.json( + { error: 'Missing authentication token' }, + { status: 401 } + ); + } + + // Missing token + if (!channelToken) { + return NextResponse.json( + { error: 'Missing authentication token' }, + { status: 401 } + ); + } + + // Verify token and extract userId + const userId = verifyWebhookToken(channelToken); + if (!userId) { + return NextResponse.json( + { error: 'Invalid authentication token' }, + { status: 401 } + ); + } + + return { userId }; +} diff --git a/apps/web/src/lib/integrations/google-calendar/webhook-token.ts b/apps/web/src/lib/integrations/google-calendar/webhook-token.ts index 8a0d2de91..8f73a070c 100644 --- a/apps/web/src/lib/integrations/google-calendar/webhook-token.ts +++ b/apps/web/src/lib/integrations/google-calendar/webhook-token.ts @@ -12,10 +12,23 @@ import crypto from 'crypto'; /** * Generate an HMAC token for webhook authentication. * Token encodes the userId so we can identify the user without a DB lookup. + * + * Fail-closed in production: throws if OAUTH_STATE_SECRET is not configured. + * This prevents registering webhooks without proper auth configuration. */ export const generateWebhookToken = (userId: string): string => { + if (userId.includes('.')) { + throw new Error('userId must not contain dots (used as token delimiter)'); + } + const secret = process.env.OAUTH_STATE_SECRET; - if (!secret) return ''; + if (!secret) { + if (process.env.NODE_ENV === 'production') { + throw new Error('OAUTH_STATE_SECRET must be configured in production'); + } + // Development: return empty string, webhook registration proceeds but auth will fail + return ''; + } const signature = crypto .createHmac('sha256', secret)