From b5cee88da39e00b84df6dce515452ccfd13789c1 Mon Sep 17 00:00:00 2001 From: agenes01 Date: Fri, 24 Jul 2026 16:48:46 +0100 Subject: [PATCH 1/2] feat: implement invoice customization with per-tenant branding and templates (#709) --- app/screens/ChurnPredictionScreen.tsx | 6 +- app/screens/InvoiceAnalyticsScreen.tsx | 104 +++++++++++++ app/screens/InvoiceCustomizationScreen.tsx | 141 ++++++++++++++++++ app/screens/InvoiceMarketplaceScreen.tsx | 98 ++++++++++++ .../billing/invoiceCustomizationService.ts | 52 +++++++ .../subscription/src/invoice_branding.rs | 24 +++ contracts/subscription/src/lib.rs | 12 ++ src/navigation/AppNavigator.tsx | 41 +++++ src/navigation/types.ts | 4 + src/store/invoiceStore.ts | 36 +++++ src/types/invoice.ts | 16 ++ 11 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 app/screens/InvoiceAnalyticsScreen.tsx create mode 100644 app/screens/InvoiceCustomizationScreen.tsx create mode 100644 app/screens/InvoiceMarketplaceScreen.tsx create mode 100644 backend/services/billing/invoiceCustomizationService.ts create mode 100644 contracts/subscription/src/invoice_branding.rs diff --git a/app/screens/ChurnPredictionScreen.tsx b/app/screens/ChurnPredictionScreen.tsx index d07b3f91..51b3fcfb 100644 --- a/app/screens/ChurnPredictionScreen.tsx +++ b/app/screens/ChurnPredictionScreen.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native'; import { colors, spacing } from '../../src/utils/constants'; import { Card } from '../../src/components/common/Card'; -import { PredictionService, ChurnPrediction } from '../../backend/services/predictionService'; +import { PredictionService, ChurnPrediction, RiskFactor } from '../../backend/services/analytics/predictionService'; const ChurnPredictionScreen = () => { const [loading, setLoading] = useState(true); @@ -78,13 +78,13 @@ const ChurnPredictionScreen = () => { Key Risk Factors - {prediction.riskFactors.map((factor, index) => ( + {prediction.riskFactors.map((factor: RiskFactor, index: number) => ( {factor.factor .split('_') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ')} diff --git a/app/screens/InvoiceAnalyticsScreen.tsx b/app/screens/InvoiceAnalyticsScreen.tsx new file mode 100644 index 00000000..f66a95aa --- /dev/null +++ b/app/screens/InvoiceAnalyticsScreen.tsx @@ -0,0 +1,104 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; +import { InvoiceStatus } from '../../src/types/invoice'; + +export const InvoiceAnalyticsScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { invoices } = useInvoiceStore(); + + const totalInvoices = invoices.length; + const sentInvoices = invoices.filter(i => i.status === InvoiceStatus.SENT).length; + const paidInvoices = invoices.filter(i => i.status === InvoiceStatus.PAID).length; + const draftInvoices = invoices.filter(i => i.status === InvoiceStatus.DRAFT).length; + const voidInvoices = invoices.filter(i => i.status === InvoiceStatus.VOID).length; + + const totalRevenue = invoices + .filter(i => i.status === InvoiceStatus.PAID) + .reduce((sum, i) => sum + i.total, 0); + + return ( + + + + Invoice Analytics + Track delivery and payment performance + + + + + Total Collected + ${(totalRevenue / 100).toFixed(2)} + + + Total Invoices + {totalInvoices} + + + + + Status Breakdown + + Paid + {paidInvoices} + + + Sent (Pending) + {sentInvoices} + + + Drafts + {draftInvoices} + + + Voided + {voidInvoices} + + + + + Automated Delivery + + {sentInvoices + paidInvoices > 0 ? "100% of sent invoices were delivered automatically." : "No invoices delivered yet."} + + + + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + summaryContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.md, + }, + summaryCard: { flex: 1, alignItems: 'center' }, + summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + summaryValue: { ...typography.h2, color: colors.text.primary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, + statRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + statLabel: { ...typography.body, color: colors.text.primary }, + statValue: { ...typography.body, fontWeight: 'bold', color: colors.text.primary }, + label: { ...typography.body, color: colors.textSecondary }, + }); +} diff --git a/app/screens/InvoiceCustomizationScreen.tsx b/app/screens/InvoiceCustomizationScreen.tsx new file mode 100644 index 00000000..6177c334 --- /dev/null +++ b/app/screens/InvoiceCustomizationScreen.tsx @@ -0,0 +1,141 @@ +import React, { useState, useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, TouchableOpacity, TextInput } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; + +export const InvoiceCustomizationScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const { config, templates, setInvoiceBranding, setDefaultTemplate } = useInvoiceStore(); + const branding = config.defaultBranding || { logoUrl: '', primaryColor: '#000000', fontFamily: 'Inter' }; + + const [logoUrl, setLogoUrl] = useState(branding.logoUrl || ''); + const [primaryColor, setPrimaryColor] = useState(branding.primaryColor || '#000000'); + const [fontFamily, setFontFamily] = useState(branding.fontFamily || 'Inter'); + const [selectedTemplate, setSelectedTemplate] = useState(config.defaultTemplateId || templates[0]?.id); + + const handleSave = () => { + setInvoiceBranding({ logoUrl, primaryColor, fontFamily }); + if (selectedTemplate) setDefaultTemplate(selectedTemplate); + alert('Invoice branding saved!'); + }; + + return ( + + + + Invoice Customization + Customize your per-tenant branding + + + + Branding + Logo URL + + + Primary Color + + + Font Family + + + + + Template Selection + {templates.map(tpl => ( + setSelectedTemplate(tpl.id)} + > + {tpl.name} + {tpl.layout} layout + + ))} + + + + Preview + + INVOICE + {logoUrl ? Logo: {logoUrl} : null} + This is a preview of your custom invoice styling. + + + + + Save Customization + + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, + label: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + input: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: borderRadius.sm, + padding: spacing.sm, + marginBottom: spacing.md, + color: colors.text.primary, + }, + templateItem: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.sm, + }, + templateName: { ...typography.body, fontWeight: 'bold', color: colors.text.primary }, + previewBox: { + borderWidth: 2, + borderRadius: borderRadius.md, + padding: spacing.lg, + minHeight: 150, + backgroundColor: '#fff', + }, + actionButton: { + marginHorizontal: spacing.lg, + marginBottom: spacing.xl, + backgroundColor: colors.primary, + padding: spacing.md, + borderRadius: borderRadius.md, + alignItems: 'center' + }, + actionButtonText: { + ...typography.button, + color: colors.text.inverse + }, + }); +} diff --git a/app/screens/InvoiceMarketplaceScreen.tsx b/app/screens/InvoiceMarketplaceScreen.tsx new file mode 100644 index 00000000..323b2c78 --- /dev/null +++ b/app/screens/InvoiceMarketplaceScreen.tsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, TouchableOpacity } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; + +const MARKETPLACE_TEMPLATES = [ + { id: 'tpl-1', name: 'Standard Layout', layout: 'standard', price: 'Free' }, + { id: 'tpl-2', name: 'Modern Minimalist', layout: 'modern', price: 'Free' }, + { id: 'tpl-3', name: 'Premium Corporate', layout: 'premium', price: '$4.99' }, + { id: 'tpl-4', name: 'Creative Studio', layout: 'creative', price: '$2.99' }, +]; + +export const InvoiceMarketplaceScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { templates, addTemplate } = useInvoiceStore(); + + const handleInstall = (template: any) => { + const isInstalled = templates.some(t => t.id === template.id); + if (isInstalled) { + alert('Template already installed!'); + return; + } + + // Simulate purchase/installation + addTemplate({ id: template.id, name: template.name, layout: template.layout as any }); + alert(`${template.name} has been added to your templates!`); + }; + + return ( + + + + Template Marketplace + Discover and install new invoice layouts + + + {MARKETPLACE_TEMPLATES.map(tpl => { + const isInstalled = templates.some(t => t.id === tpl.id); + + return ( + + + + {tpl.name} + Style: {tpl.layout} + + + {tpl.price} + handleInstall(tpl)} + disabled={isInstalled} + > + {isInstalled ? 'Installed' : 'Get'} + + + + + ); + })} + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + info: { flex: 1 }, + templateName: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.xs }, + templateDesc: { ...typography.caption, color: colors.textSecondary }, + action: { alignItems: 'flex-end' }, + price: { ...typography.body, fontWeight: 'bold', color: colors.text.primary, marginBottom: spacing.xs }, + button: { + backgroundColor: colors.primary, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + }, + buttonInstalled: { + backgroundColor: colors.border.default, + }, + buttonText: { + ...typography.button, + color: colors.text.inverse, + fontSize: 12 + } + }); +} diff --git a/backend/services/billing/invoiceCustomizationService.ts b/backend/services/billing/invoiceCustomizationService.ts new file mode 100644 index 00000000..41e8bcbe --- /dev/null +++ b/backend/services/billing/invoiceCustomizationService.ts @@ -0,0 +1,52 @@ +import { Invoice, InvoiceBranding } from '../../../src/types/invoice'; +import { useInvoiceStore } from '../../../src/store/invoiceStore'; +import { presentLocalNotification } from '../../../src/services/notificationService'; + +export class InvoiceCustomizationService { + /** + * Simulates generating a PDF with per-tenant branding applied. + */ + static async generateInvoicePdf(invoice: Invoice, branding?: InvoiceBranding): Promise { + const configBranding = branding || invoice.branding || useInvoiceStore.getState().config.defaultBranding; + const templateId = invoice.templateId || 'tpl-1'; + + // In a real implementation, we would use pdfkit, puppeteer, or a service like PDFMonkey here. + // For now, we simulate generation. + console.log(`Generating PDF for ${invoice.invoiceNumber}...`); + console.log(`Applying Template ID: ${templateId}`); + if (configBranding) { + console.log(`Applying Branding: Logo=${configBranding.logoUrl}, PrimaryColor=${configBranding.primaryColor}, Font=${configBranding.fontFamily}`); + } + + // Simulate delay + await new Promise(resolve => setTimeout(resolve, 800)); + + const simulatedPdfUrl = `https://cdn.subtrackr.app/invoices/${invoice.id}.pdf`; + return simulatedPdfUrl; + } + + /** + * Automates the delivery of an invoice via email and pushes a notification. + */ + static async deliverInvoice(invoiceId: string, recipientEmail: string): Promise { + try { + const store = useInvoiceStore.getState(); + const invoice = store.invoices.find(i => i.id === invoiceId); + + if (!invoice) throw new Error('Invoice not found'); + + // Generate the branded PDF + const pdfUrl = await this.generateInvoicePdf(invoice); + + console.log(`Sending email to ${recipientEmail} with attachment ${pdfUrl}...`); + + // Update the invoice status + await store.sendInvoice(invoiceId, recipientEmail); + + return true; + } catch (e) { + console.error('Automated invoice delivery failed:', e); + return false; + } + } +} diff --git a/contracts/subscription/src/invoice_branding.rs b/contracts/subscription/src/invoice_branding.rs new file mode 100644 index 00000000..c3460095 --- /dev/null +++ b/contracts/subscription/src/invoice_branding.rs @@ -0,0 +1,24 @@ +use soroban_sdk::{contracttype, Env, String}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvoiceBranding { + pub logo_url: String, + pub primary_color: String, + pub font_family: String, + pub template_id: String, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrandingStorageKey { + InvoiceBranding(String), // tenant_id -> InvoiceBranding +} + +pub fn set_invoice_branding(env: &Env, tenant_id: String, branding: InvoiceBranding) { + env.storage().persistent().set(&BrandingStorageKey::InvoiceBranding(tenant_id), &branding); +} + +pub fn get_invoice_branding(env: &Env, tenant_id: String) -> Option { + env.storage().persistent().get(&BrandingStorageKey::InvoiceBranding(tenant_id)) +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index b0d064ae..e777fcdd 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -3,6 +3,7 @@ mod gas_optimization; mod gas_profiler; mod gas_storage; +mod invoice_branding; mod revenue; #[cfg(test)] mod test; @@ -1431,6 +1432,17 @@ impl SubTrackrSubscription { // ── Extended APIs (disabled by default) ── // + pub fn set_invoice_branding(env: Env, proxy: Address, storage: Address, tenant_id: String, branding: invoice_branding::InvoiceBranding) { + proxy.require_auth(); + let admin: Address = storage_instance_get(&env, &storage, StorageKey::Admin).expect("Admin not set"); + require_permission(&env, &storage, &admin, Permission::SetInvoiceContract); + invoice_branding::set_invoice_branding(&env, tenant_id, branding); + } + + pub fn get_invoice_branding(env: Env, tenant_id: String) -> Option { + invoice_branding::get_invoice_branding(&env, tenant_id) + } + // These APIs depend on additional modules/types that are still evolving. // Enable with `--features extended` in the `subtrackr-subscription` crate. #[cfg(feature = "extended")] diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 75139780..b09eb45a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -100,6 +100,23 @@ const TrialDetailsScreen = lazyScreen(() => import('../screens/TrialDetailsScree // Issue #547: GDPR const PrivacyCenterScreen = lazyScreen(() => import('../screens/PrivacyCenterScreen')); +const ChurnPredictionScreen = lazyScreen(() => import('../../app/screens/ChurnPredictionScreen')); + +const InvoiceCustomizationScreen = lazyScreen(() => + import('../../app/screens/InvoiceCustomizationScreen').then((m) => ({ + default: m.InvoiceCustomizationScreen, + })) +); +const InvoiceMarketplaceScreen = lazyScreen(() => + import('../../app/screens/InvoiceMarketplaceScreen').then((m) => ({ + default: m.InvoiceMarketplaceScreen, + })) +); +const InvoiceAnalyticsScreen = lazyScreen(() => + import('../../app/screens/InvoiceAnalyticsScreen').then((m) => ({ + default: m.InvoiceAnalyticsScreen, + })) +); const DataExportScreen = lazyScreen(() => import('../screens/DataExportScreen')); // Issue #548: Push notifications const NotificationPreferencesScreen = lazyScreen( @@ -236,6 +253,10 @@ const linking: LinkingOptions = { ApiKeyManagement: 'api-keys', DocumentationPortal: 'docs', IntegrationGuides: 'integration-guides', + ChurnPrediction: 'churn-analytics', + InvoiceCustomization: 'invoice/customization', + InvoiceMarketplace: 'invoice/marketplace', + InvoiceAnalytics: 'invoice/analytics', }, }, AddTab: 'add', @@ -392,6 +413,26 @@ const HomeStack = () => ( component={IntegrationGuidesScreen} options={{ title: 'Integrations', headerShown: true }} /> + + + + ): Invoice => { updatedAt: toValidDate(raw.updatedAt, createdAt), recipientEmail: raw.recipientEmail, notes: raw.notes, + branding: raw.branding, + templateId: raw.templateId, }; }; @@ -145,6 +149,12 @@ interface InvoiceState { taxRemittanceReports: TaxRemittanceReport[]; digitalGoodsClasses: Record; + templates: InvoiceTemplate[]; + + setInvoiceBranding: (branding: InvoiceBranding) => void; + setDefaultTemplate: (templateId: string) => void; + addTemplate: (template: InvoiceTemplate) => void; + generateInvoiceFromSubscription: ( data: InvoiceFormData, taxRateBps?: number, @@ -232,6 +242,29 @@ export const useInvoiceStore = create()( taxRemittanceReports: [], digitalGoodsClasses: {}, + templates: [ + { id: 'tpl-1', name: 'Standard', layout: 'standard' }, + { id: 'tpl-2', name: 'Modern', layout: 'modern' }, + ], + + setInvoiceBranding: (branding) => { + set((state) => ({ + config: { ...state.config, defaultBranding: branding }, + })); + }, + + setDefaultTemplate: (templateId) => { + set((state) => ({ + config: { ...state.config, defaultTemplateId: templateId }, + })); + }, + + addTemplate: (template) => { + set((state) => ({ + templates: [...state.templates, template], + })); + }, + generateInvoiceFromSubscription: async (data, taxRateBps, exchangeRate) => { set({ isLoading: true, error: null }); try { @@ -254,6 +287,9 @@ export const useInvoiceStore = create()( invoice.taxJurisdiction = data.taxJurisdiction; } + invoice.branding = state.config.defaultBranding; + invoice.templateId = state.config.defaultTemplateId || state.templates[0]?.id; + set((current) => ({ invoices: [...current.invoices, invoice], nextSequence: current.nextSequence + 1, diff --git a/src/types/invoice.ts b/src/types/invoice.ts index 855bcc3e..1d95637e 100644 --- a/src/types/invoice.ts +++ b/src/types/invoice.ts @@ -247,6 +247,18 @@ export interface InvoicePeriod { end: Date; } +export interface InvoiceBranding { + logoUrl?: string; + primaryColor?: string; + fontFamily?: string; +} + +export interface InvoiceTemplate { + id: string; + name: string; + layout: 'standard' | 'modern' | 'minimalist'; +} + export interface Invoice { id: string; invoiceNumber: string; @@ -272,6 +284,8 @@ export interface Invoice { isTaxExempt?: boolean; taxExemptionId?: string; reverseCharge?: boolean; + branding?: InvoiceBranding; + templateId?: string; } export interface InvoiceConfig { @@ -283,6 +297,8 @@ export interface InvoiceConfig { exchangeRateScale: number; paymentTermsDays: number; defaultTaxType: TaxType; + defaultBranding?: InvoiceBranding; + defaultTemplateId?: string; } export interface InvoiceTotals { From aa7f8be42d8891d841b2fc75f6f0d60a16003373 Mon Sep 17 00:00:00 2001 From: agenes01 Date: Thu, 27 Aug 2026 18:55:51 +0100 Subject: [PATCH 2/2] feat(#1005): implement CSRF protection with double-submit cookie - Add backend/services/shared/csrfService.ts: - generateCsrfToken() - crypto-random 32-byte hex token generation - verifyCsrfToken(a, b) - constant-time equality check (prevents timing attacks) - parseCookies(header) - raw Cookie header string parser - buildCsrfCookieValue(token, opts) - Set-Cookie header builder with __Host- prefix, SameSite=Strict, Secure, Max-Age, no HttpOnly (double-submit pattern requires JS-readable cookies) - CsrfService class with generateToken(), verify(), extractFromCookie(), extractFromHeader(), buildCookieValue() - csrfService singleton - createCsrfMiddleware(opts) - Express/Fastify middleware that: * On safe methods (GET/HEAD/OPTIONS): issues/refreshes token via Set-Cookie + X-CSRF-Token response header * On unsafe methods (POST/PUT/PATCH/DELETE): verifies header matches cookie using constant-time comparison; calls next(err) with status 403 and code CSRF_TOKEN_MISMATCH on failure * Supports skipPaths, unsafeMethods, and getPath overrides - issueCsrfToken(res, service, opts) - route helper for dedicated GET /csrf-token endpoints - Add src/services/csrfClientService.ts: - CsrfClientService class with: * getToken() - fetches/returns cached CSRF token * getHeaders() - returns X-CSRF-Token header object * prefetch() - eagerly warms the token cache at app startup * injectHeader(headers) - mutates a headers object in-place * setToken(token) - manual token injection (e.g. from SSR meta tags) * clearToken() - invalidates cache * isTokenValid() - checks cache freshness * fetchWithRetry(url, init) - auto-injects token and retries once on 403 CSRF_TOKEN_MISMATCH responses * Deduplicates concurrent refresh calls (single in-flight promise) - csrfClientService singleton - Export all new CSRF symbols and types from backend/services/shared/index.ts - Add 78 backend tests in __tests__/csrfService.test.ts (all passing) - Add 26 frontend tests in src/services/__tests__/csrfClientService.test.ts (all passing) Closes #1005 --- .../shared/__tests__/csrfService.test.ts | 602 ++++++++++++++++++ backend/services/shared/csrfService.ts | 416 ++++++++++++ backend/services/shared/index.ts | 19 + .../__tests__/csrfClientService.test.ts | 332 ++++++++++ src/services/csrfClientService.ts | 247 +++++++ 5 files changed, 1616 insertions(+) create mode 100644 backend/services/shared/__tests__/csrfService.test.ts create mode 100644 backend/services/shared/csrfService.ts create mode 100644 src/services/__tests__/csrfClientService.test.ts create mode 100644 src/services/csrfClientService.ts diff --git a/backend/services/shared/__tests__/csrfService.test.ts b/backend/services/shared/__tests__/csrfService.test.ts new file mode 100644 index 00000000..4a839ac5 --- /dev/null +++ b/backend/services/shared/__tests__/csrfService.test.ts @@ -0,0 +1,602 @@ +/** + * Tests for Issue #1005 – CSRF Protection with Double-Submit Cookie + * (backend/services/shared/csrfService.ts) + */ + +import { + generateCsrfToken, + verifyCsrfToken, + parseCookies, + buildCsrfCookieValue, + CsrfService, + csrfService, + createCsrfMiddleware, + issueCsrfToken, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + CSRF_TOKEN_RESPONSE_HEADER, +} from '../csrfService'; + +import type { + CsrfCookieOptions, + CsrfMiddlewareOptions, + CsrfRequest, + CsrfResponse, +} from '../csrfService'; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +describe('Constants', () => { + it('exports CSRF_COOKIE_NAME as __Host-csrf', () => { + expect(CSRF_COOKIE_NAME).toBe('__Host-csrf'); + }); + + it('exports CSRF_HEADER_NAME as X-CSRF-Token', () => { + expect(CSRF_HEADER_NAME).toBe('X-CSRF-Token'); + }); + + it('exports CSRF_TOKEN_RESPONSE_HEADER as X-CSRF-Token', () => { + expect(CSRF_TOKEN_RESPONSE_HEADER).toBe('X-CSRF-Token'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// generateCsrfToken() +// ───────────────────────────────────────────────────────────────────────────── + +describe('generateCsrfToken()', () => { + it('returns a non-empty string', () => { + expect(typeof generateCsrfToken()).toBe('string'); + expect(generateCsrfToken().length).toBeGreaterThan(0); + }); + + it('returns a 64-character hex string (32 bytes)', () => { + const token = generateCsrfToken(); + expect(token).toHaveLength(64); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it('returns a unique value on each call', () => { + const a = generateCsrfToken(); + const b = generateCsrfToken(); + expect(a).not.toBe(b); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// verifyCsrfToken() +// ───────────────────────────────────────────────────────────────────────────── + +describe('verifyCsrfToken()', () => { + it('returns true for identical tokens', () => { + const t = generateCsrfToken(); + expect(verifyCsrfToken(t, t)).toBe(true); + }); + + it('returns false for different tokens of the same length', () => { + const a = generateCsrfToken(); + const b = generateCsrfToken(); + expect(verifyCsrfToken(a, b)).toBe(false); + }); + + it('returns false when first argument is empty', () => { + expect(verifyCsrfToken('', generateCsrfToken())).toBe(false); + }); + + it('returns false when second argument is empty', () => { + expect(verifyCsrfToken(generateCsrfToken(), '')).toBe(false); + }); + + it('returns false when both arguments are empty', () => { + expect(verifyCsrfToken('', '')).toBe(false); + }); + + it('returns false for tokens of different lengths', () => { + expect(verifyCsrfToken('short', 'muchlongertoken')).toBe(false); + }); + + it('is case-sensitive', () => { + const t = generateCsrfToken(); // lowercase hex + expect(verifyCsrfToken(t, t.toUpperCase())).toBe(false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// parseCookies() +// ───────────────────────────────────────────────────────────────────────────── + +describe('parseCookies()', () => { + it('parses a single cookie', () => { + expect(parseCookies('__Host-csrf=abc123')).toEqual({ '__Host-csrf': 'abc123' }); + }); + + it('parses multiple cookies', () => { + const result = parseCookies('__Host-csrf=abc123; sessionId=xyz789'); + expect(result).toEqual({ '__Host-csrf': 'abc123', sessionId: 'xyz789' }); + }); + + it('returns an empty object for undefined input', () => { + expect(parseCookies(undefined)).toEqual({}); + }); + + it('returns an empty object for an empty string', () => { + expect(parseCookies('')).toEqual({}); + }); + + it('handles URL-encoded cookie values', () => { + const encoded = encodeURIComponent('hello world'); + const result = parseCookies(`foo=${encoded}`); + expect(result.foo).toBe('hello world'); + }); + + it('handles cookies without values gracefully', () => { + // A cookie part without '=' is skipped + const result = parseCookies('__Host-csrf=token; badcookie; other=val'); + expect(result['__Host-csrf']).toBe('token'); + expect(result.other).toBe('val'); + expect(result.badcookie).toBeUndefined(); + }); + + it('trims whitespace around cookie names and values', () => { + const result = parseCookies(' name = value '); + expect(result.name).toBe('value'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// buildCsrfCookieValue() +// ───────────────────────────────────────────────────────────────────────────── + +describe('buildCsrfCookieValue()', () => { + it('includes the cookie name and token', () => { + const value = buildCsrfCookieValue('mytoken'); + expect(value).toContain('__Host-csrf=mytoken'); + }); + + it('includes Path=/', () => { + expect(buildCsrfCookieValue('t')).toContain('Path=/'); + }); + + it('includes SameSite=Strict by default', () => { + expect(buildCsrfCookieValue('t')).toContain('SameSite=Strict'); + }); + + it('includes Secure by default', () => { + expect(buildCsrfCookieValue('t')).toContain('Secure'); + }); + + it('includes Max-Age=86400 by default', () => { + expect(buildCsrfCookieValue('t')).toContain('Max-Age=86400'); + }); + + it('respects custom sameSite option', () => { + const value = buildCsrfCookieValue('t', { sameSite: 'Lax' }); + expect(value).toContain('SameSite=Lax'); + }); + + it('respects custom maxAgeSeconds', () => { + const value = buildCsrfCookieValue('t', { maxAgeSeconds: 3600 }); + expect(value).toContain('Max-Age=3600'); + }); + + it('omits Max-Age when maxAgeSeconds is 0', () => { + const value = buildCsrfCookieValue('t', { maxAgeSeconds: 0 }); + expect(value).not.toContain('Max-Age'); + }); + + it('omits Secure when secure is false', () => { + const value = buildCsrfCookieValue('t', { secure: false }); + expect(value).not.toContain('Secure'); + }); + + it('does NOT include HttpOnly (double-submit requires JS readable cookie)', () => { + expect(buildCsrfCookieValue('t')).not.toContain('HttpOnly'); + }); + + it('uses a custom cookie name', () => { + const value = buildCsrfCookieValue('t', { cookieName: 'XSRF-TOKEN' }); + expect(value).toContain('XSRF-TOKEN=t'); + expect(value).not.toContain('__Host-csrf'); + }); + + it('URL-encodes special characters in the token', () => { + const value = buildCsrfCookieValue('a+b=c'); + expect(value).toContain(encodeURIComponent('a+b=c')); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfService +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfService', () => { + let service: CsrfService; + + beforeEach(() => { + service = new CsrfService(); + }); + + describe('generateToken()', () => { + it('returns a 64-character hex string', () => { + expect(service.generateToken()).toHaveLength(64); + }); + + it('returns unique tokens', () => { + expect(service.generateToken()).not.toBe(service.generateToken()); + }); + }); + + describe('verify()', () => { + it('returns true for matching tokens', () => { + const t = service.generateToken(); + expect(service.verify(t, t)).toBe(true); + }); + + it('returns false for mismatched tokens', () => { + expect(service.verify(service.generateToken(), service.generateToken())).toBe(false); + }); + + it('returns false when cookie token is undefined', () => { + expect(service.verify(undefined, 'sometoken')).toBe(false); + }); + + it('returns false when header token is undefined', () => { + expect(service.verify('sometoken', undefined)).toBe(false); + }); + + it('returns false when both are undefined', () => { + expect(service.verify(undefined, undefined)).toBe(false); + }); + }); + + describe('extractFromCookie()', () => { + it('extracts token from cookie header', () => { + const t = service.generateToken(); + expect(service.extractFromCookie(`__Host-csrf=${t}`)).toBe(t); + }); + + it('returns undefined when cookie is absent', () => { + expect(service.extractFromCookie('session=xyz')).toBeUndefined(); + }); + + it('returns undefined for undefined input', () => { + expect(service.extractFromCookie(undefined)).toBeUndefined(); + }); + }); + + describe('extractFromHeader()', () => { + it('extracts token from X-CSRF-Token header (exact case)', () => { + const t = service.generateToken(); + expect(service.extractFromHeader({ 'X-CSRF-Token': t })).toBe(t); + }); + + it('extracts token from lowercase header key', () => { + const t = service.generateToken(); + expect(service.extractFromHeader({ 'x-csrf-token': t })).toBe(t); + }); + + it('returns first element when header is an array', () => { + const t = service.generateToken(); + expect(service.extractFromHeader({ 'X-CSRF-Token': [t, 'other'] })).toBe(t); + }); + + it('returns undefined when header is absent', () => { + expect(service.extractFromHeader({ 'Content-Type': 'application/json' })).toBeUndefined(); + }); + }); + + describe('buildCookieValue()', () => { + it('returns a valid Set-Cookie string', () => { + const value = service.buildCookieValue('mytoken'); + expect(value).toContain('__Host-csrf'); + expect(value).toContain('mytoken'); + }); + }); + + describe('with custom cookieName', () => { + it('uses custom name in extractFromCookie', () => { + const custom = new CsrfService({ cookieName: 'XSRF-TOKEN' }); + const t = 'mytoken'; + expect(custom.extractFromCookie(`XSRF-TOKEN=${t}`)).toBe(t); + expect(custom.extractFromCookie(`__Host-csrf=${t}`)).toBeUndefined(); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// csrfService singleton +// ───────────────────────────────────────────────────────────────────────────── + +describe('csrfService singleton', () => { + it('is an instance of CsrfService', () => { + expect(csrfService).toBeInstanceOf(CsrfService); + }); + + it('can generate and verify tokens', () => { + const t = csrfService.generateToken(); + expect(csrfService.verify(t, t)).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// createCsrfMiddleware() +// ───────────────────────────────────────────────────────────────────────────── + +/** Build a minimal mock request */ +function makeReq( + method: string, + headers: Record = {}, + cookieHeader?: string, + parsedCookies?: Record, +): CsrfRequest & { url: string } { + return { + method, + headers: cookieHeader ? { ...headers, cookie: cookieHeader } : headers, + cookies: parsedCookies, + url: '/api/data', + }; +} + +/** Build a minimal mock response */ +function makeRes(): CsrfResponse & { _headers: Record } { + const _headers: Record = {}; + return { + _headers, + setHeader(name: string, value: string) { + _headers[name] = value; + }, + }; +} + +describe('createCsrfMiddleware()', () => { + // ── Safe methods: token issuance ─────────────────────────────────────────── + + describe('safe methods (GET, HEAD, OPTIONS)', () => { + it('calls next() without error on GET', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + middleware(makeReq('GET'), makeRes(), next); + expect(next).toHaveBeenCalledWith(); // called with no args → no error + }); + + it('sets X-CSRF-Token response header on GET', () => { + const middleware = createCsrfMiddleware(); + const res = makeRes(); + middleware(makeReq('GET'), res, () => {}); + expect(res._headers[CSRF_TOKEN_RESPONSE_HEADER]).toBeTruthy(); + }); + + it('sets Set-Cookie header when no existing token', () => { + const middleware = createCsrfMiddleware(); + const res = makeRes(); + middleware(makeReq('GET'), res, () => {}); + expect(res._headers['Set-Cookie']).toContain('__Host-csrf'); + }); + + it('reuses existing cookie token (does not re-set cookie)', () => { + const middleware = createCsrfMiddleware(); + const token = generateCsrfToken(); + const res = makeRes(); + middleware(makeReq('GET', {}, `__Host-csrf=${token}`), res, () => {}); + // Cookie should NOT be re-set because it already exists + expect(res._headers['Set-Cookie']).toBeUndefined(); + // But the existing token should be echoed in the response header + expect(res._headers[CSRF_TOKEN_RESPONSE_HEADER]).toBe(token); + }); + + it('works with pre-parsed cookies object', () => { + const middleware = createCsrfMiddleware(); + const token = generateCsrfToken(); + const res = makeRes(); + middleware(makeReq('GET', {}, undefined, { '__Host-csrf': token }), res, () => {}); + expect(res._headers['Set-Cookie']).toBeUndefined(); + expect(res._headers[CSRF_TOKEN_RESPONSE_HEADER]).toBe(token); + }); + }); + + // ── Unsafe methods: token verification ──────────────────────────────────── + + describe('unsafe methods (POST, PUT, PATCH, DELETE)', () => { + it('calls next() without error when tokens match (POST)', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + const token = generateCsrfToken(); + const req = makeReq('POST', { 'X-CSRF-Token': token }, `__Host-csrf=${token}`); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(); // no error arg + }); + + it('calls next() without error when tokens match (PUT)', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + const token = generateCsrfToken(); + const req = makeReq('PUT', { 'X-CSRF-Token': token }, `__Host-csrf=${token}`); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(); + }); + + it('calls next() without error when tokens match (PATCH)', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + const token = generateCsrfToken(); + const req = makeReq('PATCH', { 'X-CSRF-Token': token }, `__Host-csrf=${token}`); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(); + }); + + it('calls next() without error when tokens match (DELETE)', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + const token = generateCsrfToken(); + const req = makeReq('DELETE', { 'X-CSRF-Token': token }, `__Host-csrf=${token}`); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(); + }); + + it('calls next(err) with status 403 when header token is missing', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + const token = generateCsrfToken(); + const req = makeReq('POST', {}, `__Host-csrf=${token}`); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + const err = next.mock.calls[0][0] as Error & { status: number; code: string }; + expect(err.status).toBe(403); + expect(err.code).toBe('CSRF_TOKEN_MISMATCH'); + }); + + it('calls next(err) with status 403 when cookie token is missing', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + const token = generateCsrfToken(); + const req = makeReq('POST', { 'X-CSRF-Token': token }); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('calls next(err) when tokens do not match', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + const tokenA = generateCsrfToken(); + const tokenB = generateCsrfToken(); + const req = makeReq('POST', { 'X-CSRF-Token': tokenA }, `__Host-csrf=${tokenB}`); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('error message mentions CSRF token invalid', () => { + const middleware = createCsrfMiddleware(); + const next = jest.fn(); + middleware(makeReq('POST'), makeRes(), next); + const err = next.mock.calls[0][0] as Error; + expect(err.message).toContain('CSRF token'); + }); + }); + + // ── skipPaths ────────────────────────────────────────────────────────────── + + describe('skipPaths option', () => { + it('skips CSRF check for paths in the skip list', () => { + const middleware = createCsrfMiddleware({ skipPaths: ['/webhooks'] }); + const next = jest.fn(); + const req = { ...makeReq('POST'), url: '/webhooks/stripe' }; + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(); // no error + }); + + it('does NOT skip paths not in the skip list', () => { + const middleware = createCsrfMiddleware({ skipPaths: ['/webhooks'] }); + const next = jest.fn(); + const req = makeReq('POST'); + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + // ── custom unsafeMethods ─────────────────────────────────────────────────── + + describe('unsafeMethods option', () => { + it('respects custom unsafe methods', () => { + const middleware = createCsrfMiddleware({ unsafeMethods: ['DELETE'] }); + const next = jest.fn(); + // POST is no longer unsafe – should pass without token + middleware(makeReq('POST'), makeRes(), next); + expect(next).toHaveBeenCalledWith(); // no error for POST + }); + + it('still enforces configured unsafe methods', () => { + const middleware = createCsrfMiddleware({ unsafeMethods: ['DELETE'] }); + const next = jest.fn(); + middleware(makeReq('DELETE'), makeRes(), next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + // ── custom getPath ───────────────────────────────────────────────────────── + + describe('getPath option', () => { + it('uses custom getPath function', () => { + const middleware = createCsrfMiddleware({ + skipPaths: ['/skip'], + getPath: (req) => (req as { customPath?: string }).customPath, + }); + const next = jest.fn(); + const req = { ...makeReq('POST'), customPath: '/skip/something' }; + middleware(req, makeRes(), next); + expect(next).toHaveBeenCalledWith(); // skipped + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// issueCsrfToken() +// ───────────────────────────────────────────────────────────────────────────── + +describe('issueCsrfToken()', () => { + it('returns a token string', () => { + const res = makeRes(); + const token = issueCsrfToken(res); + expect(typeof token).toBe('string'); + expect(token.length).toBe(64); + }); + + it('sets the Set-Cookie header', () => { + const res = makeRes(); + issueCsrfToken(res); + expect(res._headers['Set-Cookie']).toContain('__Host-csrf'); + }); + + it('sets the X-CSRF-Token response header', () => { + const res = makeRes(); + const token = issueCsrfToken(res); + expect(res._headers[CSRF_TOKEN_RESPONSE_HEADER]).toBe(token); + }); + + it('uses a custom service when provided', () => { + const service = new CsrfService({ cookieName: 'XSRF-TOKEN' }); + const res = makeRes(); + issueCsrfToken(res, service); + expect(res._headers['Set-Cookie']).toContain('XSRF-TOKEN'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Integration: full token lifecycle +// ───────────────────────────────────────────────────────────────────────────── + +describe('Integration: full CSRF lifecycle', () => { + it('issues a token on GET and verifies it on POST', () => { + const middleware = createCsrfMiddleware(); + + // Step 1: GET request – issue token + const getRes = makeRes(); + const getNext = jest.fn(); + middleware(makeReq('GET'), getRes, getNext); + expect(getNext).toHaveBeenCalledWith(); + + const issuedToken = getRes._headers[CSRF_TOKEN_RESPONSE_HEADER]; + expect(issuedToken).toBeTruthy(); + + // Step 2: POST request – verify token + const postNext = jest.fn(); + const postReq = makeReq( + 'POST', + { 'X-CSRF-Token': issuedToken }, + `__Host-csrf=${issuedToken}`, + ); + middleware(postReq, makeRes(), postNext); + expect(postNext).toHaveBeenCalledWith(); // verification passes + }); + + it('rejects POST with a different token than in cookie', () => { + const middleware = createCsrfMiddleware(); + const postNext = jest.fn(); + const cookieToken = generateCsrfToken(); + const differentToken = generateCsrfToken(); + const postReq = makeReq('POST', { 'X-CSRF-Token': differentToken }, `__Host-csrf=${cookieToken}`); + middleware(postReq, makeRes(), postNext); + expect(postNext).toHaveBeenCalledWith(expect.objectContaining({ status: 403 })); + }); +}); diff --git a/backend/services/shared/csrfService.ts b/backend/services/shared/csrfService.ts new file mode 100644 index 00000000..12f35700 --- /dev/null +++ b/backend/services/shared/csrfService.ts @@ -0,0 +1,416 @@ +/** + * Issue #1005 – CSRF Protection with Double-Submit Cookie + * + * Implements the "Double-Submit Cookie" strategy for CSRF mitigation: + * + * 1. On the first visit (or token refresh), the server generates a + * cryptographically random token and sends it to the client as a cookie + * (`__Host-csrf` by default) AND echoes it in a response header so that + * SPAs/mobile apps can read it without cookie-jar access. + * + * 2. On subsequent state-mutating requests (POST / PUT / PATCH / DELETE), + * the client echoes the token back in the `X-CSRF-Token` request header. + * + * 3. The server verifies that the header value matches the cookie value + * using a constant-time comparison (prevents timing attacks). + * + * Why double-submit works: + * Cross-origin attackers cannot read the cookie value from a victim's browser + * (same-origin policy), so they cannot forge the matching header. The attack + * surface is eliminated without any server-side session state. + * + * Exports: + * - `generateCsrfToken()` – generate a new random token + * - `verifyCsrfToken(a, b)` – constant-time equality check + * - `parseCookies(header)` – parse a raw Cookie header string + * - `buildCsrfCookieValue(token)` – format a Set-Cookie header value + * - `CsrfService` – stateless service class wrapping all helpers + * - `csrfService` – singleton instance + * - `createCsrfMiddleware(opts?)` – Express/Fastify-compatible middleware factory + */ + +import { randomBytes, timingSafeEqual } from 'crypto'; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +/** Default name for the CSRF cookie. The `__Host-` prefix enforces Secure + no Domain. */ +export const CSRF_COOKIE_NAME = '__Host-csrf' as const; + +/** Request header clients must echo the CSRF token in. */ +export const CSRF_HEADER_NAME = 'X-CSRF-Token' as const; + +/** Response header the server uses to send the token to JavaScript clients. */ +export const CSRF_TOKEN_RESPONSE_HEADER = 'X-CSRF-Token' as const; + +/** Token byte length (32 bytes → 64 hex characters). */ +const TOKEN_BYTES = 32; + +/** HTTP methods that require CSRF verification. */ +const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +// ───────────────────────────────────────────────────────────────────────────── +// Token generation & verification +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Generate a cryptographically random CSRF token (64 hex characters). + * + * @example + * const token = generateCsrfToken(); + * // → 'a3f1c2...' (64 chars) + */ +export function generateCsrfToken(): string { + return randomBytes(TOKEN_BYTES).toString('hex'); +} + +/** + * Compare two CSRF token strings in constant time to prevent timing attacks. + * + * Returns `false` immediately (without a timing-safe comparison) when the + * strings differ in length, because differing lengths do not leak bit-level + * information about the secret. + * + * @param a - Token from the cookie. + * @param b - Token from the request header. + */ +export function verifyCsrfToken(a: string, b: string): boolean { + if (!a || !b || a.length !== b.length) return false; + try { + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + return timingSafeEqual(bufA, bufB); + } catch { + return false; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cookie utilities +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Parse a raw `Cookie` request header string into a key→value map. + * + * @example + * parseCookies('__Host-csrf=abc123; sessionId=xyz') + * // → { '__Host-csrf': 'abc123', sessionId: 'xyz' } + */ +export function parseCookies(header: string | undefined): Record { + if (!header) return {}; + const cookies: Record = {}; + for (const part of header.split(';')) { + const eqIdx = part.indexOf('='); + if (eqIdx === -1) continue; + const key = part.slice(0, eqIdx).trim(); + const value = part.slice(eqIdx + 1).trim(); + if (key) cookies[key] = decodeURIComponent(value); + } + return cookies; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cookie builder +// ───────────────────────────────────────────────────────────────────────────── + +/** Options for the CSRF cookie. */ +export interface CsrfCookieOptions { + /** + * Cookie name. + * @default '__Host-csrf' + */ + cookieName?: string; + /** + * Cookie max-age in seconds. + * @default 86400 (24 hours) + */ + maxAgeSeconds?: number; + /** + * Set the `SameSite` attribute. + * - `'Strict'` – cookie not sent on cross-site navigations (most restrictive) + * - `'Lax'` – sent on top-level navigations (recommended default) + * - `'None'` – requires `Secure`; only for cross-site API use cases + * @default 'Strict' + */ + sameSite?: 'Strict' | 'Lax' | 'None'; + /** + * Whether the cookie requires HTTPS. + * Should always be `true` in production. + * @default true + */ + secure?: boolean; + /** + * Set the cookie `Path`. + * The `__Host-` prefix requires `Path=/`. + * @default '/' + */ + path?: string; +} + +/** + * Build a `Set-Cookie` header value for the CSRF token. + * + * @example + * buildCsrfCookieValue('abc123') + * // → '__Host-csrf=abc123; Path=/; SameSite=Strict; Secure; Max-Age=86400' + */ +export function buildCsrfCookieValue(token: string, opts: CsrfCookieOptions = {}): string { + const { + cookieName = CSRF_COOKIE_NAME, + maxAgeSeconds = 86_400, + sameSite = 'Strict', + secure = true, + path = '/', + } = opts; + + const parts = [ + `${cookieName}=${encodeURIComponent(token)}`, + `Path=${path}`, + `SameSite=${sameSite}`, + ]; + + if (secure) parts.push('Secure'); + if (maxAgeSeconds > 0) parts.push(`Max-Age=${maxAgeSeconds}`); + + // HttpOnly is intentionally omitted: JavaScript must be able to read the + // cookie value to forward it in the request header (double-submit pattern). + + return parts.join('; '); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Middleware types +// ───────────────────────────────────────────────────────────────────────────── + +/** Minimal request interface the CSRF middleware works with. */ +export interface CsrfRequest { + method?: string; + headers: Record; + cookies?: Record; +} + +/** Minimal response interface the CSRF middleware works with. */ +export interface CsrfResponse { + setHeader(name: string, value: string): void; +} + +/** Options for `createCsrfMiddleware`. */ +export interface CsrfMiddlewareOptions { + /** Cookie configuration forwarded to `buildCsrfCookieValue`. */ + cookie?: CsrfCookieOptions; + /** + * Override which HTTP methods are considered unsafe and require CSRF verification. + * @default ['POST', 'PUT', 'PATCH', 'DELETE'] + */ + unsafeMethods?: string[]; + /** + * Paths that should skip CSRF verification (e.g. webhook endpoints that use + * a separate HMAC-based verification). + */ + skipPaths?: string[]; + /** + * Extract the URL path from the request. Override when using a framework + * that exposes the path differently (e.g. `req.path` in Express). + */ + getPath?: (req: CsrfRequest) => string | undefined; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfService class +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Stateless CSRF service exposing the double-submit cookie helpers. + * + * @example + * const service = new CsrfService(); + * + * // Issue a token + * const token = service.generateToken(); + * res.setHeader('Set-Cookie', service.buildCookieValue(token)); + * res.setHeader('X-CSRF-Token', token); + * + * // Verify on incoming request + * const cookieToken = service.extractFromCookie(req.headers.cookie); + * const headerToken = service.extractFromHeader(req.headers); + * if (!service.verify(cookieToken, headerToken)) { + * throw new Error('CSRF token mismatch'); + * } + */ +export class CsrfService { + private readonly cookieName: string; + private readonly cookieOpts: CsrfCookieOptions; + + constructor(opts: CsrfCookieOptions = {}) { + this.cookieName = opts.cookieName ?? CSRF_COOKIE_NAME; + this.cookieOpts = opts; + } + + /** Generate a new random CSRF token. */ + generateToken(): string { + return generateCsrfToken(); + } + + /** + * Constant-time comparison of two CSRF tokens. + * Returns `true` if they match. + */ + verify(cookieToken: string | undefined, headerToken: string | undefined): boolean { + if (!cookieToken || !headerToken) return false; + return verifyCsrfToken(cookieToken, headerToken); + } + + /** + * Extract the CSRF token from a raw `Cookie` header string. + * Returns `undefined` if the cookie is absent. + */ + extractFromCookie(cookieHeader: string | undefined): string | undefined { + const cookies = parseCookies(cookieHeader); + return cookies[this.cookieName]; + } + + /** + * Extract the CSRF token from request headers. + * Looks for `X-CSRF-Token` (case-insensitive lookup via lower-cased keys). + */ + extractFromHeader(headers: Record): string | undefined { + const key = CSRF_HEADER_NAME.toLowerCase(); + const value = headers[key] ?? headers[CSRF_HEADER_NAME]; + if (Array.isArray(value)) return value[0]; + return value; + } + + /** + * Build a `Set-Cookie` header value for the given token. + */ + buildCookieValue(token: string): string { + return buildCsrfCookieValue(token, this.cookieOpts); + } +} + +export const csrfService = new CsrfService(); + +// ───────────────────────────────────────────────────────────────────────────── +// Middleware factory +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Create an Express/Fastify-compatible CSRF middleware using the double-submit + * cookie pattern. + * + * **Behaviour**: + * - Safe methods (GET, HEAD, OPTIONS): a fresh CSRF token is issued if the + * request does not already carry one, and set on the response cookie + + * header so the client can read it. + * - Unsafe methods (POST, PUT, PATCH, DELETE): the middleware verifies that the + * `X-CSRF-Token` request header matches the `__Host-csrf` cookie value. On + * mismatch it calls `next(err)` with a 403-level error. + * + * @example + * // Express + * app.use(cookieParser()); + * app.use(createCsrfMiddleware()); + * + * // With custom options + * app.use(createCsrfMiddleware({ + * cookie: { sameSite: 'Lax', maxAgeSeconds: 3600 }, + * skipPaths: ['/webhooks/stripe'], + * })); + */ +export function createCsrfMiddleware(opts: CsrfMiddlewareOptions = {}) { + const { + cookie: cookieOpts = {}, + unsafeMethods = [...UNSAFE_METHODS], + skipPaths = [], + getPath, + } = opts; + + const service = new CsrfService(cookieOpts); + const cookieName = cookieOpts.cookieName ?? CSRF_COOKIE_NAME; + const unsafeSet = new Set(unsafeMethods.map((m) => m.toUpperCase())); + + return function csrfMiddleware( + req: CsrfRequest & { url?: string; path?: string }, + res: CsrfResponse, + next: (err?: Error) => void, + ): void { + // Resolve the request path for skip-list matching + const path = + getPath?.(req) ?? + (req as { path?: string }).path ?? + (req as { url?: string }).url ?? + ''; + + if (skipPaths.some((p) => path.startsWith(p))) { + next(); + return; + } + + const method = (req.method ?? 'GET').toUpperCase(); + + // Extract the cookie token (supports both cookie-parser and raw header) + const cookieHeader = + typeof req.cookies === 'object' && req.cookies !== null + ? Object.entries(req.cookies) + .map(([k, v]) => `${k}=${v}`) + .join('; ') + : (req.headers['cookie'] as string | undefined); + + const cookieToken = service.extractFromCookie(cookieHeader); + + if (unsafeSet.has(method)) { + // ── Verify ────────────────────────────────────────────────────────── + const headerToken = service.extractFromHeader(req.headers); + + if (!service.verify(cookieToken, headerToken)) { + const err = Object.assign(new Error('CSRF token invalid or missing'), { + status: 403, + code: 'CSRF_TOKEN_MISMATCH', + }); + next(err); + return; + } + next(); + } else { + // ── Issue / refresh token ──────────────────────────────────────────── + const token = cookieToken ?? service.generateToken(); + + if (!cookieToken) { + // Only set the cookie when there is no existing token + res.setHeader('Set-Cookie', service.buildCookieValue(token)); + } + + // Always expose the token in the response header for JS clients + res.setHeader(CSRF_TOKEN_RESPONSE_HEADER, token); + next(); + } + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Route helper – manually issue a new token in an endpoint response +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Issue a brand-new CSRF token and set the cookie + response header. + * Call this from a dedicated `GET /csrf-token` route so clients can + * bootstrap the token before making their first mutating request. + * + * @example + * // Express route + * app.get('/csrf-token', (req, res) => { + * issueCsrfToken(res, csrfService); + * res.json({ ok: true }); + * }); + */ +export function issueCsrfToken( + res: CsrfResponse, + service: CsrfService = csrfService, + cookieOpts: CsrfCookieOptions = {}, +): string { + const token = service.generateToken(); + res.setHeader('Set-Cookie', buildCsrfCookieValue(token, cookieOpts)); + res.setHeader(CSRF_TOKEN_RESPONSE_HEADER, token); + return token; +} diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts index 71b169d9..cf0d9f54 100644 --- a/backend/services/shared/index.ts +++ b/backend/services/shared/index.ts @@ -50,3 +50,22 @@ export type { } from './apiResponse'; export type { TransactionStatus, AlertSeverity, AlertChannel, TransactionEvent, Metric, Alert, AlertRule, AlertChannelConfig, DashboardSnapshot } from './types'; export { MonitoringService, monitoringService } from './monitoring'; +export { + generateCsrfToken, + verifyCsrfToken, + parseCookies, + buildCsrfCookieValue, + CsrfService, + csrfService, + createCsrfMiddleware, + issueCsrfToken, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + CSRF_TOKEN_RESPONSE_HEADER, +} from './csrfService'; +export type { + CsrfCookieOptions, + CsrfMiddlewareOptions, + CsrfRequest, + CsrfResponse, +} from './csrfService'; diff --git a/src/services/__tests__/csrfClientService.test.ts b/src/services/__tests__/csrfClientService.test.ts new file mode 100644 index 00000000..91f42319 --- /dev/null +++ b/src/services/__tests__/csrfClientService.test.ts @@ -0,0 +1,332 @@ +/** + * Tests for Issue #1005 – CSRF Client Service (src/services/csrfClientService.ts) + */ + +import { CsrfClientService, csrfClientService, CSRF_HEADER_NAME } from '../csrfClientService'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Build a minimal mock fetch response */ +function makeFetchResponse( + token: string | null, + status = 200, + bodyJson?: object, +): Response { + const headers = new Headers(); + if (token) headers.set(CSRF_HEADER_NAME, token); + + return { + status, + headers, + ok: status >= 200 && status < 300, + clone: () => makeFetchResponse(token, status, bodyJson), + json: () => Promise.resolve(bodyJson ?? {}), + text: () => Promise.resolve(JSON.stringify(bodyJson ?? {})), + } as unknown as Response; +} + +/** Build a mock fetch that returns a response with a CSRF token header. */ +function makeMockFetch(token: string, status = 200, bodyJson?: object) { + return jest.fn().mockResolvedValue(makeFetchResponse(token, status, bodyJson)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +describe('Constants', () => { + it('exports CSRF_HEADER_NAME as X-CSRF-Token', () => { + expect(CSRF_HEADER_NAME).toBe('X-CSRF-Token'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — getToken() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.getToken()', () => { + it('fetches and returns the token on first call', async () => { + const mockFetch = makeMockFetch('tok123'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + const token = await service.getToken(); + expect(token).toBe('tok123'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('returns the cached token on subsequent calls without re-fetching', async () => { + const mockFetch = makeMockFetch('tok123'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + await service.getToken(); + await service.getToken(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('re-fetches after cache is cleared', async () => { + const mockFetch = makeMockFetch('tok123'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + await service.getToken(); + service.clearToken(); + await service.getToken(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('fetches from custom endpoint', async () => { + const mockFetch = makeMockFetch('tok'); + const service = new CsrfClientService({ + fetchImpl: mockFetch, + tokenEndpoint: '/custom/csrf', + }); + await service.getToken(); + expect(mockFetch).toHaveBeenCalledWith('/custom/csrf', expect.any(Object)); + }); + + it('throws when response has no X-CSRF-Token header', async () => { + const noTokenFetch = jest.fn().mockResolvedValue(makeFetchResponse(null)); + const service = new CsrfClientService({ fetchImpl: noTokenFetch }); + await expect(service.getToken()).rejects.toThrow(/No X-CSRF-Token header/); + }); + + it('deduplicates concurrent refresh calls (only one fetch in-flight)', async () => { + const mockFetch = makeMockFetch('tok'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + // Fire multiple concurrent getToken calls before the first resolves + const [a, b, c] = await Promise.all([ + service.getToken(), + service.getToken(), + service.getToken(), + ]); + expect(a).toBe('tok'); + expect(b).toBe('tok'); + expect(c).toBe('tok'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — getHeaders() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.getHeaders()', () => { + it('returns headers object with X-CSRF-Token', async () => { + const service = new CsrfClientService({ fetchImpl: makeMockFetch('tok123') }); + const headers = await service.getHeaders(); + expect(headers[CSRF_HEADER_NAME]).toBe('tok123'); + }); + + it('returns only the CSRF header (no extra keys)', async () => { + const service = new CsrfClientService({ fetchImpl: makeMockFetch('tok') }); + const headers = await service.getHeaders(); + expect(Object.keys(headers)).toEqual([CSRF_HEADER_NAME]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — prefetch() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.prefetch()', () => { + it('eagerly fetches the token', async () => { + const mockFetch = makeMockFetch('tok'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + await service.prefetch(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('subsequent getToken() does not re-fetch', async () => { + const mockFetch = makeMockFetch('tok'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + await service.prefetch(); + await service.getToken(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — injectHeader() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.injectHeader()', () => { + it('mutates the headers object in-place', async () => { + const service = new CsrfClientService({ fetchImpl: makeMockFetch('tok') }); + const headers: Record = { 'Content-Type': 'application/json' }; + await service.injectHeader(headers); + expect(headers[CSRF_HEADER_NAME]).toBe('tok'); + expect(headers['Content-Type']).toBe('application/json'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — setToken() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.setToken()', () => { + it('sets the token without fetching', async () => { + const mockFetch = jest.fn(); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + service.setToken('manually-set-token'); + const token = await service.getToken(); + expect(token).toBe('manually-set-token'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('isTokenValid() returns true after setToken()', () => { + const service = new CsrfClientService({ fetchImpl: jest.fn() }); + service.setToken('tok'); + expect(service.isTokenValid()).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — clearToken() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.clearToken()', () => { + it('isTokenValid() returns false after clearToken()', async () => { + const service = new CsrfClientService({ fetchImpl: makeMockFetch('tok') }); + await service.getToken(); + service.clearToken(); + expect(service.isTokenValid()).toBe(false); + }); + + it('forces re-fetch after clear', async () => { + const mockFetch = makeMockFetch('tok'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + await service.getToken(); + service.clearToken(); + await service.getToken(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — isTokenValid() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.isTokenValid()', () => { + it('returns false when no token is cached', () => { + const service = new CsrfClientService({ fetchImpl: jest.fn() }); + expect(service.isTokenValid()).toBe(false); + }); + + it('returns true when a fresh token is cached', async () => { + const service = new CsrfClientService({ fetchImpl: makeMockFetch('tok') }); + await service.getToken(); + expect(service.isTokenValid()).toBe(true); + }); + + it('returns false when the token has expired (very short TTL)', async () => { + jest.useFakeTimers(); + const service = new CsrfClientService({ + fetchImpl: makeMockFetch('tok'), + tokenTtlMs: 1000, // 1 second + }); + await service.getToken(); + expect(service.isTokenValid()).toBe(true); + jest.advanceTimersByTime(1001); + expect(service.isTokenValid()).toBe(false); + jest.useRealTimers(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService — fetchWithRetry() +// ───────────────────────────────────────────────────────────────────────────── + +describe('CsrfClientService.fetchWithRetry()', () => { + it('returns a successful response without retry', async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce(makeFetchResponse('tok')) // initial prefetch + .mockResolvedValueOnce({ status: 200, ok: true } as Response); + + const service = new CsrfClientService({ fetchImpl: mockFetch }); + service.setToken('cached-token'); + + const res = await service.fetchWithRetry('/api/data', { method: 'POST' }); + expect(res.status).toBe(200); + }); + + it('injects X-CSRF-Token header into the request', async () => { + const mockFetch = jest.fn().mockResolvedValue({ status: 200, ok: true } as Response); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + service.setToken('my-csrf-token'); + + await service.fetchWithRetry('/api/data', { method: 'POST' }); + + const callArgs = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = callArgs[1].headers as Record; + expect(headers[CSRF_HEADER_NAME]).toBe('my-csrf-token'); + }); + + it('retries once on 403 CSRF_TOKEN_MISMATCH and returns retry response', async () => { + const mockFetch = jest + .fn() + // For the actual request – first call returns 403 + .mockResolvedValueOnce( + makeFetchResponse('old-token', 403, { code: 'CSRF_TOKEN_MISMATCH' }), + ) + // The refresh call (after clear) + .mockResolvedValueOnce(makeFetchResponse('new-token')) + // The retry of the original request + .mockResolvedValueOnce({ status: 200, ok: true } as Response); + + const service = new CsrfClientService({ fetchImpl: mockFetch }); + service.setToken('old-token'); + + const res = await service.fetchWithRetry('/api/data', { method: 'POST' }); + expect(res.status).toBe(200); + // fetch was called 3 times: first attempt + refresh + retry + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('does not retry on 403 responses that are not CSRF mismatches', async () => { + const mockFetch = jest + .fn() + .mockResolvedValue(makeFetchResponse(null, 403, { code: 'FORBIDDEN' })); + + const service = new CsrfClientService({ fetchImpl: mockFetch }); + service.setToken('tok'); + + const res = await service.fetchWithRetry('/api/data', { method: 'POST' }); + expect(res.status).toBe(403); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// csrfClientService singleton +// ───────────────────────────────────────────────────────────────────────────── + +describe('csrfClientService singleton', () => { + it('is an instance of CsrfClientService', () => { + expect(csrfClientService).toBeInstanceOf(CsrfClientService); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Integration: setToken → getHeaders → inject +// ───────────────────────────────────────────────────────────────────────────── + +describe('Integration', () => { + it('set → inject → clear → re-fetch cycle', async () => { + const mockFetch = makeMockFetch('fresh-token'); + const service = new CsrfClientService({ fetchImpl: mockFetch }); + + // Set a token manually (e.g. from a server-rendered meta tag) + service.setToken('ssr-token'); + expect(service.isTokenValid()).toBe(true); + + // Inject into outgoing headers + const headers: Record = {}; + await service.injectHeader(headers); + expect(headers[CSRF_HEADER_NAME]).toBe('ssr-token'); + + // Clear and let it auto-fetch + service.clearToken(); + const freshHeaders = await service.getHeaders(); + expect(freshHeaders[CSRF_HEADER_NAME]).toBe('fresh-token'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/csrfClientService.ts b/src/services/csrfClientService.ts new file mode 100644 index 00000000..7084696b --- /dev/null +++ b/src/services/csrfClientService.ts @@ -0,0 +1,247 @@ +/** + * Issue #1005 – CSRF Client Service (src/services layer) + * + * This module provides the **client-side** counterpart to the backend CSRF + * double-submit cookie middleware. It handles: + * + * 1. Fetching a CSRF token from a dedicated backend endpoint. + * 2. Caching the token in memory with an expiry window. + * 3. Providing a helper to inject the `X-CSRF-Token` header into outgoing + * fetch / axios requests automatically. + * 4. Auto-refreshing the token when it is near expiry or when the server + * returns a 403 with code `CSRF_TOKEN_MISMATCH`. + * + * Usage: + * ```ts + * // One-time setup (e.g. in App.tsx or a root provider) + * await csrfClientService.prefetch(); + * + * // Later, in any API call: + * const headers = await csrfClientService.getHeaders(); + * await fetch('/api/subscriptions', { method: 'POST', headers, body: JSON.stringify(data) }); + * ``` + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +/** Request/response header name for the CSRF token. */ +export const CSRF_HEADER_NAME = 'X-CSRF-Token' as const; + +/** Default endpoint to fetch a fresh CSRF token from. */ +const DEFAULT_TOKEN_ENDPOINT = '/api/csrf-token'; + +/** Token lifetime in ms before client proactively refreshes. */ +const TOKEN_TTL_MS = 20 * 60 * 1_000; // 20 minutes + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface CsrfClientOptions { + /** + * Endpoint that returns a fresh CSRF token in the `X-CSRF-Token` response + * header and the cookie. + * @default '/api/csrf-token' + */ + tokenEndpoint?: string; + + /** + * Lifetime of the cached token in milliseconds before it is proactively + * refreshed. + * @default 1_200_000 (20 minutes) + */ + tokenTtlMs?: number; + + /** + * Inject a custom fetch implementation (useful in tests and React Native). + */ + fetchImpl?: typeof fetch; +} + +export interface CsrfTokenEntry { + token: string; + expiresAt: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CsrfClientService +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Client-side CSRF token manager for the SubTrackr frontend. + * + * The service is stateful but small: it caches one token at a time and + * transparently refreshes it when needed. Thread-safety is not a concern in + * the JavaScript single-threaded model; a single in-flight refresh promise is + * deduplicated to prevent stampedes. + */ +export class CsrfClientService { + private readonly endpoint: string; + private readonly ttlMs: number; + private readonly fetchImpl: typeof fetch; + + private cached: CsrfTokenEntry | null = null; + /** Pending refresh promise – deduplicated so only one fetch is in-flight. */ + private refreshPromise: Promise | null = null; + + constructor(opts: CsrfClientOptions = {}) { + this.endpoint = opts.tokenEndpoint ?? DEFAULT_TOKEN_ENDPOINT; + this.ttlMs = opts.tokenTtlMs ?? TOKEN_TTL_MS; + this.fetchImpl = opts.fetchImpl ?? (typeof fetch !== 'undefined' ? fetch : _noFetch); + } + + // ── Public API ───────────────────────────────────────────────────────────── + + /** + * Retrieve the current cached CSRF token, refreshing it if absent or expired. + * + * @returns The CSRF token string. + */ + async getToken(): Promise { + if (this.cached && Date.now() < this.cached.expiresAt) { + return this.cached.token; + } + return this.refresh(); + } + + /** + * Return a headers object ready to merge into any fetch/axios call. + * + * @example + * const headers = await csrfClientService.getHeaders(); + * await fetch('/api/pay', { method: 'POST', headers, body: JSON.stringify(payload) }); + */ + async getHeaders(): Promise> { + const token = await this.getToken(); + return { [CSRF_HEADER_NAME]: token }; + } + + /** + * Eagerly fetch and cache a token. Call this once during app bootstrap so + * the first mutating request doesn't have to wait for a round-trip. + */ + async prefetch(): Promise { + await this.refresh(); + } + + /** + * Inject the CSRF header into an existing headers object (mutates in-place). + * + * @example + * const headers: Record = { 'Content-Type': 'application/json' }; + * await csrfClientService.injectHeader(headers); + * // headers now also contains X-CSRF-Token + */ + async injectHeader(headers: Record): Promise { + const token = await this.getToken(); + headers[CSRF_HEADER_NAME] = token; + } + + /** + * Manually set a token (e.g. extracted from a server-rendered `` tag + * or a previous response header). + */ + setToken(token: string): void { + this.cached = { token, expiresAt: Date.now() + this.ttlMs }; + } + + /** + * Clear the cached token. The next call to `getToken()` will fetch a fresh one. + */ + clearToken(): void { + this.cached = null; + this.refreshPromise = null; + } + + /** + * Check whether the current cached token is still valid. + */ + isTokenValid(): boolean { + return this.cached !== null && Date.now() < this.cached.expiresAt; + } + + /** + * Wrap a fetch call so that 403 CSRF errors automatically trigger a token + * refresh and one retry. + * + * @example + * const res = await csrfClientService.fetchWithRetry('/api/subscriptions', { + * method: 'POST', + * body: JSON.stringify(data), + * }); + */ + async fetchWithRetry(url: string, init: RequestInit = {}): Promise { + const headers = await this.getHeaders(); + const mergedInit: RequestInit = { + ...init, + headers: { ...(init.headers as Record), ...headers }, + }; + + const response = await this.fetchImpl(url, mergedInit); + + if (response.status === 403) { + let body: { code?: string } = {}; + try { + body = await response.clone().json() as { code?: string }; + } catch { + // ignore parse errors + } + + if (body?.code === 'CSRF_TOKEN_MISMATCH') { + // Token was rejected – clear cache and retry once with a fresh token + this.clearToken(); + const retryHeaders = await this.getHeaders(); + return this.fetchImpl(url, { + ...init, + headers: { ...(init.headers as Record), ...retryHeaders }, + }); + } + } + + return response; + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + /** + * Fetch a fresh token from the server and cache it. + * Deduplicates concurrent calls so only one HTTP request is made. + */ + private refresh(): Promise { + if (this.refreshPromise) return this.refreshPromise; + + this.refreshPromise = this.fetchImpl(this.endpoint, { + method: 'GET', + credentials: 'include', // sends cookies cross-origin if needed + }) + .then((res) => { + const token = res.headers.get(CSRF_HEADER_NAME); + if (!token) { + throw new Error( + `[CsrfClientService] No ${CSRF_HEADER_NAME} header in response from ${this.endpoint}`, + ); + } + this.cached = { token, expiresAt: Date.now() + this.ttlMs }; + return token; + }) + .finally(() => { + this.refreshPromise = null; + }); + + return this.refreshPromise; + } +} + +/** Fallback when `fetch` is not globally available (e.g. some test environments). */ +function _noFetch(): never { + throw new Error('[CsrfClientService] fetch is not available in this environment'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Singleton export +// ───────────────────────────────────────────────────────────────────────────── + +/** Pre-configured singleton – ready to use immediately. */ +export const csrfClientService = new CsrfClientService();