diff --git a/backend/src/index.ts b/backend/src/index.ts index 37e57c51..7195a29e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -168,6 +168,7 @@ import { jobQueueRouter } from './routes/job-queue.js'; import { recurringBillingRouter } from './routes/recurring-billing.js'; import { splitPaymentsRouter } from './routes/split-payments.js'; import { transfersRouter } from './routes/transfers.js'; +import { threeDSRouter } from './routes/3ds.js'; // Validate environment variables at startup validateEnv(); @@ -397,6 +398,8 @@ apiV1Router.use('/job-queue', jobQueueRouter); apiV1Router.use('/recurring-payments', recurringBillingRouter); apiV1Router.use('/split-payments', splitPaymentsRouter); apiV1Router.use('/transfers', transfersRouter); +// 3D Secure card-payment authentication — Issue #925 +apiV1Router.use('/3ds', threeDSRouter); apiV1Router.get('/compression/metrics', (_req, res) => { res.json(getCompressionMetrics()); }); diff --git a/backend/src/routes/3ds.ts b/backend/src/routes/3ds.ts new file mode 100644 index 00000000..2da1e85a --- /dev/null +++ b/backend/src/routes/3ds.ts @@ -0,0 +1,133 @@ +/** + * 3DS Authentication Routes + * + * Exposes RESTful endpoints for the 3D Secure card-payment authentication flow. + * + * POST /api/v1/3ds/initiate Initiate a 3DS session for a payment intent + * POST /api/v1/3ds/verify Verify the outcome of a 3DS challenge + * GET /api/v1/3ds/status/:id Get the current state of a 3DS session + * POST /api/v1/3ds/cancel Cancel an active 3DS session + */ + +import { Router } from 'express'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + initiate3DSSchema, + verify3DSSchema, + cancel3DSSchema, + get3DSStatusSchema, +} from '../schemas/3ds.js'; +import { + initiate3DS, + verify3DS, + get3DSSession, + cancel3DSSession, +} from '../services/3ds.js'; + +export const threeDSRouter = Router(); + +/** + * POST /api/v1/3ds/initiate + * + * Begin a 3DS authentication session. The caller provides the payment intent + * together with card-identification data (full PAN, token, or BIN). + * + * Response contains the ACS redirect URL / encoded challenge request so the + * caller can present the cardholder with the issuer challenge. + */ +threeDSRouter.post( + '/initiate', + validate(initiate3DSSchema), + asyncHandler(async (req, res) => { + const result = initiate3DS(req.body); + const statusCode = result.status === 'error' ? 500 : 201; + res.status(statusCode).json({ data: result }); + }), +); + +/** + * POST /api/v1/3ds/verify + * + * Complete the 3DS flow by submitting the challenge result. + * For 3DS 1.x supply the PARes; for 3DS 2.x supply the transStatus value. + * + * On success the response contains the ECI value and Authentication Value + * (CAVV/AAV) that must be forwarded to the authorisation request. + */ +threeDSRouter.post( + '/verify', + validate(verify3DSSchema), + asyncHandler(async (req, res) => { + const result = verify3DS(req.body); + + if (result.status === 'error') { + throw new AppError(404, result.message ?? '3DS session not found', '3DS_SESSION_NOT_FOUND'); + } + + if (result.status === 'failed') { + // Return 200 with failed status so callers can distinguish between + // "request error" and "cardholder declined authentication" + res.status(200).json({ data: result }); + return; + } + + res.status(200).json({ data: result }); + }), +); + +/** + * GET /api/v1/3ds/status/:id + * + * Poll the current state of a 3DS session. Useful for async challenge windows + * where the front-end needs to wait for the issuer ACS postback. + */ +threeDSRouter.get( + '/status/:id', + asyncHandler(async (req, res) => { + // Validate the id param manually (lightweight, no body schema needed) + const parsed = get3DSStatusSchema.safeParse({ sessionId: req.params.id }); + if (!parsed.success) { + throw new AppError(400, 'Invalid session ID', 'VALIDATION_ERROR', parsed.error.errors); + } + + const session = get3DSSession(parsed.data.sessionId); + if (!session) { + throw new AppError(404, 'Session not found', '3DS_SESSION_NOT_FOUND'); + } + + res.status(200).json({ + data: { + threeDSSessionId: session.sessionId, + paymentIntentId: session.paymentIntentId, + status: session.status, + version: session.version, + enrolled: session.enrolled, + eci: session.eci, + authenticationValue: session.authenticationValue, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + }, + }); + }), +); + +/** + * POST /api/v1/3ds/cancel + * + * Cancel an active 3DS session (e.g. the cardholder abandons the challenge). + */ +threeDSRouter.post( + '/cancel', + validate(cancel3DSSchema), + asyncHandler(async (req, res) => { + const { threeDSSessionId, reason } = req.body; + const result = cancel3DSSession(threeDSSessionId, reason); + + if (!result.success) { + throw new AppError(404, result.message, '3DS_SESSION_NOT_FOUND'); + } + + res.status(200).json({ data: result }); + }), +); diff --git a/backend/src/routes/__tests__/3ds.test.ts b/backend/src/routes/__tests__/3ds.test.ts new file mode 100644 index 00000000..8649efbd --- /dev/null +++ b/backend/src/routes/__tests__/3ds.test.ts @@ -0,0 +1,248 @@ +/** + * Route-level tests – 3DS Authentication Endpoints + * + * These tests exercise the HTTP layer (request validation, response shape) + * without spinning up a real HTTP server. We instantiate the router directly + * and call through to the service, which uses its in-memory store. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + initiate3DS, + verify3DS, + cancel3DSSession, + _clearSessions, +} from '../../services/3ds.js'; +import { + initiate3DSSchema, + verify3DSSchema, + cancel3DSSchema, +} from '../../schemas/3ds.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const BASE_INITIATE = { + paymentIntentId: 'pi_route_test_001', + amount: 200, + currency: 'USD', + returnUrl: 'https://example.com/callback', + merchantId: 'merch_route_01', +}; + +// BIN 400000 → enrolled (even last digit) +const ENROLLED_CARD = { cardBin: '400000' }; +// BIN 411111 → not enrolled (odd last digit) +const NOT_ENROLLED_CARD = { cardBin: '411111' }; + +beforeEach(() => { + _clearSessions(); +}); + +// ── Schema validation ───────────────────────────────────────────────────────── + +describe('Initiate3DS schema', () => { + it('accepts valid initiation payload with cardBin', () => { + const result = initiate3DSSchema.safeParse({ ...BASE_INITIATE, ...ENROLLED_CARD }); + expect(result.success).toBe(true); + }); + + it('accepts valid payload with cardToken', () => { + const result = initiate3DSSchema.safeParse({ + ...BASE_INITIATE, + cardToken: 'tok_400000_xyz', + }); + expect(result.success).toBe(true); + }); + + it('accepts valid payload with full cardNumber', () => { + const result = initiate3DSSchema.safeParse({ + ...BASE_INITIATE, + cardNumber: '4000001111111119', + }); + expect(result.success).toBe(true); + }); + + it('rejects when no card identifier is supplied', () => { + const result = initiate3DSSchema.safeParse({ ...BASE_INITIATE }); + expect(result.success).toBe(false); + }); + + it('rejects invalid cardNumber (letters)', () => { + const result = initiate3DSSchema.safeParse({ + ...BASE_INITIATE, + cardNumber: 'not-a-card-number', + }); + expect(result.success).toBe(false); + }); + + it('rejects non-URL returnUrl', () => { + const result = initiate3DSSchema.safeParse({ + ...BASE_INITIATE, + ...ENROLLED_CARD, + returnUrl: 'not-a-url', + }); + expect(result.success).toBe(false); + }); + + it('rejects non-3-char currency code', () => { + const result = initiate3DSSchema.safeParse({ + ...BASE_INITIATE, + ...ENROLLED_CARD, + currency: 'US', + }); + expect(result.success).toBe(false); + }); + + it('rejects negative amount', () => { + const result = initiate3DSSchema.safeParse({ + ...BASE_INITIATE, + ...ENROLLED_CARD, + amount: -50, + }); + expect(result.success).toBe(false); + }); +}); + +describe('Verify3DS schema', () => { + it('accepts valid verify payload with transStatus', () => { + const result = verify3DSSchema.safeParse({ + threeDSSessionId: 'sess-001', + paymentIntentId: 'pi_001', + transStatus: 'Y', + }); + expect(result.success).toBe(true); + }); + + it('accepts valid verify payload with paRes', () => { + const result = verify3DSSchema.safeParse({ + threeDSSessionId: 'sess-001', + paymentIntentId: 'pi_001', + paRes: 'BASE64_DATA', + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid transStatus values', () => { + const result = verify3DSSchema.safeParse({ + threeDSSessionId: 'sess-001', + paymentIntentId: 'pi_001', + transStatus: 'X', + }); + expect(result.success).toBe(false); + }); + + it('rejects empty threeDSSessionId', () => { + const result = verify3DSSchema.safeParse({ + threeDSSessionId: '', + paymentIntentId: 'pi_001', + }); + expect(result.success).toBe(false); + }); +}); + +describe('Cancel3DS schema', () => { + it('accepts minimal cancel payload', () => { + const result = cancel3DSSchema.safeParse({ threeDSSessionId: 'sess-001' }); + expect(result.success).toBe(true); + }); + + it('accepts cancel with optional reason', () => { + const result = cancel3DSSchema.safeParse({ + threeDSSessionId: 'sess-001', + reason: 'User abandoned', + }); + expect(result.success).toBe(true); + }); + + it('rejects empty threeDSSessionId', () => { + const result = cancel3DSSchema.safeParse({ threeDSSessionId: '' }); + expect(result.success).toBe(false); + }); +}); + +// ── Service integration through route-layer logic ───────────────────────────── + +describe('3DS end-to-end flow (service layer)', () => { + it('happy path: initiate → verify (enrolled card, 3DS2)', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_CARD }); + + expect(init.enrolled).toBe(true); + expect(init.status).toBe('challenge_required'); + + const verify = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + transStatus: 'Y', + }); + + expect(verify.authenticated).toBe(true); + expect(verify.status).toBe('authenticated'); + expect(verify.eci).toBeTruthy(); + expect(verify.authenticationValue).toBeTruthy(); + }); + + it('handles not-enrolled card: initiate returns not_enrolled', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...NOT_ENROLLED_CARD }); + + expect(init.enrolled).toBe(false); + expect(init.status).toBe('not_enrolled'); + }); + + it('cancel flow: initiate → cancel returns success', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_CARD }); + const cancel = cancel3DSSession(init.threeDSSessionId, 'Cardholder closed window'); + + expect(cancel.success).toBe(true); + }); + + it('verify returns error for unknown session ID', () => { + const result = verify3DS({ + threeDSSessionId: 'does-not-exist', + paymentIntentId: 'pi_xxx', + transStatus: 'Y', + }); + + expect(result.status).toBe('error'); + expect(result.authenticated).toBe(false); + }); + + it('frictionless sandbox flow completes without challenge', () => { + const init = initiate3DS( + { ...BASE_INITIATE, ...ENROLLED_CARD }, + { sandboxFrictionless: true }, + ); + + expect(init.status).toBe('authenticated'); + expect(init.enrolled).toBe(true); + + // Verify should report already-authenticated + const verify = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + transStatus: 'Y', + }); + + expect(verify.authenticated).toBe(true); + }); + + it('multiple sessions are independent', () => { + const init1 = initiate3DS({ ...BASE_INITIATE, paymentIntentId: 'pi_a', ...ENROLLED_CARD }); + const init2 = initiate3DS({ ...BASE_INITIATE, paymentIntentId: 'pi_b', ...ENROLLED_CARD }); + + expect(init1.threeDSSessionId).not.toBe(init2.threeDSSessionId); + + const v1 = verify3DS({ + threeDSSessionId: init1.threeDSSessionId, + paymentIntentId: 'pi_a', + transStatus: 'Y', + }); + const v2 = verify3DS({ + threeDSSessionId: init2.threeDSSessionId, + paymentIntentId: 'pi_b', + transStatus: 'N', + }); + + expect(v1.authenticated).toBe(true); + expect(v2.authenticated).toBe(false); + }); +}); diff --git a/backend/src/schemas/3ds.ts b/backend/src/schemas/3ds.ts new file mode 100644 index 00000000..e7a3c6bd --- /dev/null +++ b/backend/src/schemas/3ds.ts @@ -0,0 +1,101 @@ +/** + * 3DS Authentication Schemas + * Zod validation schemas for 3D Secure authentication endpoints + */ + +import { z } from 'zod'; + +// ── Initiate 3DS challenge ──────────────────────────────────────────────────── + +export const initiate3DSSchema = z.object({ + paymentIntentId: z.string().min(1, 'paymentIntentId is required'), + cardNumber: z + .string() + .regex(/^\d{13,19}$/, 'cardNumber must be 13–19 digits') + .optional(), + /** PAN token returned from a previous tokenisation call */ + cardToken: z.string().min(1).optional(), + amount: z.number().positive('amount must be a positive number'), + currency: z.string().length(3, 'currency must be a 3-letter ISO 4217 code').toUpperCase(), + returnUrl: z.string().url('returnUrl must be a valid URL'), + merchantId: z.string().min(1, 'merchantId is required'), + customerId: z.string().optional(), + /** BIN (first 6–8 digits) used for issuer routing when full PAN is unavailable */ + cardBin: z.string().regex(/^\d{6,8}$/).optional(), +}).refine( + (d) => d.cardNumber !== undefined || d.cardToken !== undefined || d.cardBin !== undefined, + { message: 'Provide at least one of cardNumber, cardToken, or cardBin' }, +); + +// ── Verify 3DS result ───────────────────────────────────────────────────────── + +export const verify3DSSchema = z.object({ + threeDSSessionId: z.string().min(1, 'threeDSSessionId is required'), + /** Value returned by the issuer ACS after the challenge */ + paRes: z.string().optional(), + /** EMV 3DS 2.x: challenge result indicator (e.g. "Y", "N", "A", "U") */ + transStatus: z.enum(['Y', 'N', 'A', 'U', 'C', 'R']).optional(), + paymentIntentId: z.string().min(1, 'paymentIntentId is required'), +}); + +// ── Get 3DS status ──────────────────────────────────────────────────────────── + +export const get3DSStatusSchema = z.object({ + sessionId: z.string().min(1, 'sessionId is required'), +}); + +// ── Cancel 3DS session ──────────────────────────────────────────────────────── + +export const cancel3DSSchema = z.object({ + threeDSSessionId: z.string().min(1, 'threeDSSessionId is required'), + reason: z.string().max(255).optional(), +}); + +// ── Response types ──────────────────────────────────────────────────────────── + +export const ThreeDSStatusEnum = z.enum([ + 'pending', + 'challenge_required', + 'authenticated', + 'failed', + 'cancelled', + 'not_enrolled', + 'error', +]); + +export const ThreeDSVersionEnum = z.enum(['1.0', '2.1', '2.2']); + +export const ThreeDSInitResponseSchema = z.object({ + threeDSSessionId: z.string(), + status: ThreeDSStatusEnum, + version: ThreeDSVersionEnum, + acsUrl: z.string().url().optional(), + pareq: z.string().optional(), + /** Base64-encoded AReq for 3DS2 */ + creq: z.string().optional(), + termUrl: z.string().url().optional(), + enrolled: z.boolean(), + message: z.string().optional(), +}); + +export const ThreeDSVerifyResponseSchema = z.object({ + threeDSSessionId: z.string(), + paymentIntentId: z.string(), + status: ThreeDSStatusEnum, + eci: z.string().optional(), + authenticationValue: z.string().optional(), + transactionId: z.string().optional(), + authenticated: z.boolean(), + message: z.string().optional(), +}); + +// ── Exported TypeScript types ───────────────────────────────────────────────── + +export type Initiate3DSRequest = z.infer; +export type Verify3DSRequest = z.infer; +export type Get3DSStatusRequest = z.infer; +export type Cancel3DSRequest = z.infer; +export type ThreeDSStatus = z.infer; +export type ThreeDSVersion = z.infer; +export type ThreeDSInitResponse = z.infer; +export type ThreeDSVerifyResponse = z.infer; diff --git a/backend/src/services/3ds.ts b/backend/src/services/3ds.ts new file mode 100644 index 00000000..4fdb6662 --- /dev/null +++ b/backend/src/services/3ds.ts @@ -0,0 +1,404 @@ +/** + * 3DS Authentication Service + * + * Implements the 3D Secure authentication flow for card payments. + * + * Supported spec versions: + * - 3DS 1.0.2 (legacy PARes/PAReq redirect flow) + * - EMV 3DS 2.1 / 2.2 (frictionless + challenge flows) + * + * In production this layer would delegate to a PSP SDK (Stripe, Adyen, Braintree…). + * Here we provide a complete, deterministic implementation backed by in-memory + * stores so the feature can be fully tested without external dependencies. + */ + +import { randomUUID, createHmac } from 'node:crypto'; +import { + type Initiate3DSRequest, + type Verify3DSRequest, + type ThreeDSStatus, + type ThreeDSVersion, + type ThreeDSInitResponse, + type ThreeDSVerifyResponse, +} from '../schemas/3ds.js'; + +// ── Internal types ──────────────────────────────────────────────────────────── + +export interface ThreeDSSession { + sessionId: string; + paymentIntentId: string; + merchantId: string; + customerId?: string; + amount: number; + currency: string; + status: ThreeDSStatus; + version: ThreeDSVersion; + enrolled: boolean; + acsUrl?: string; + pareq?: string; + creq?: string; + eci?: string; + authenticationValue?: string; + transactionId?: string; + createdAt: Date; + updatedAt: Date; + expiresAt: Date; +} + +export interface ThreeDSConfig { + /** Session TTL in seconds (default 15 minutes) */ + sessionTtlSeconds?: number; + /** Simulated ACS base URL used for test environments */ + acsBaseUrl?: string; + /** Whether to force frictionless flow in sandbox mode */ + sandboxFrictionless?: boolean; + /** HMAC secret for signing challenge data */ + hmacSecret?: string; +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const DEFAULT_TTL_SECONDS = 900; // 15 minutes +const DEFAULT_ACS_URL = 'https://acs.example.com/3ds/challenge'; +const DEFAULT_HMAC_SECRET = 'threeds-dev-secret'; + +// ── In-memory store (replace with Redis / DB in production) ────────────────── + +const sessions = new Map(); + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function now(): Date { + return new Date(); +} + +function expiresAt(ttlSeconds: number): Date { + return new Date(Date.now() + ttlSeconds * 1000); +} + +/** + * Determine whether a card BIN / number is enrolled in 3DS. + * + * In production this calls the Directory Server or your PSP's enrollment API. + * Here we use a simple deterministic rule based on the last digit of the BIN so + * that tests can control enrolment by choosing an appropriate card number. + * + * BIN last digit even → enrolled + * BIN last digit odd → not enrolled + */ +function isCardEnrolled(cardBin: string): boolean { + const lastDigit = parseInt(cardBin.slice(-1), 10); + return lastDigit % 2 === 0; +} + +/** + * Derive a 6–8 digit BIN from whatever card identifier was supplied. + */ +function extractBin(req: Initiate3DSRequest): string { + if (req.cardBin) return req.cardBin; + if (req.cardNumber) return req.cardNumber.slice(0, 6); + if (req.cardToken) { + // Tokens are prefixed with the masked BIN: tok__ + const parts = req.cardToken.split('_'); + if (parts.length >= 2 && /^\d{6,8}$/.test(parts[1])) { + return parts[1]; + } + } + // Default to a deterministically enrolled BIN for test tokens + return '400000'; +} + +/** + * Select the 3DS spec version for this BIN. + * + * In reality this is returned by the Directory Server. Here we map + * BIN ranges to versions in a simple way for deterministic tests. + */ +function detectVersion(bin: string): ThreeDSVersion { + const prefix = parseInt(bin.slice(0, 4), 10); + if (prefix >= 5100 && prefix <= 5599) return '2.2'; // Mastercard range + if (prefix >= 4000 && prefix <= 4999) return '2.1'; // Visa range + return '1.0'; +} + +/** + * Build a base64-encoded PAReq (3DS 1.x) for the ACS redirect. + */ +function buildPareq(session: ThreeDSSession, termUrl: string): string { + const payload = JSON.stringify({ + MD: session.sessionId, + PaReq: Buffer.from( + `${session.paymentIntentId}:${session.amount}:${session.currency}`, + ).toString('base64'), + TermUrl: termUrl, + }); + return Buffer.from(payload).toString('base64'); +} + +/** + * Build a base64-encoded CReq (EMV 3DS 2.x) for the challenge window. + */ +function buildCreq(session: ThreeDSSession): string { + const payload = JSON.stringify({ + messageType: 'CReq', + messageVersion: session.version, + threeDSServerTransID: session.transactionId, + acsTransID: randomUUID(), + challengeWindowSize: '05', + }); + return Buffer.from(payload).toString('base64'); +} + +/** + * Derive an ECI (Electronic Commerce Indicator) from the auth result. + * + * Visa: 05 = fully authenticated, 06 = attempted, 07 = failed + * Mastercard: 02 = fully authenticated, 01 = attempted, 00 = failed + */ +function deriveEci(version: ThreeDSVersion, transStatus: string): string { + // Use Visa coding for simplicity + if (transStatus === 'Y') return '05'; + if (transStatus === 'A') return '06'; + return '07'; +} + +/** + * Generate a deterministic Authentication Value (CAVV / AAV) for the session. + */ +function generateAuthValue(sessionId: string, hmacSecret: string): string { + return createHmac('sha256', hmacSecret).update(sessionId).digest('base64'); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Initiate a 3DS authentication session for a card payment. + */ +export function initiate3DS( + req: Initiate3DSRequest, + cfg: ThreeDSConfig = {}, +): ThreeDSInitResponse { + const ttl = cfg.sessionTtlSeconds ?? DEFAULT_TTL_SECONDS; + const acsBase = cfg.acsBaseUrl ?? DEFAULT_ACS_URL; + const sandboxFrictionless = cfg.sandboxFrictionless ?? false; + + const bin = extractBin(req); + const enrolled = isCardEnrolled(bin); + const version = detectVersion(bin); + const sessionId = randomUUID(); + const transactionId = randomUUID(); + const termUrl = `${req.returnUrl}?sessionId=${sessionId}`; + + const ts = now(); + + const session: ThreeDSSession = { + sessionId, + paymentIntentId: req.paymentIntentId, + merchantId: req.merchantId, + customerId: req.customerId, + amount: req.amount, + currency: req.currency, + status: enrolled ? 'pending' : 'not_enrolled', + version, + enrolled, + transactionId, + createdAt: ts, + updatedAt: ts, + expiresAt: expiresAt(ttl), + }; + + if (enrolled) { + session.acsUrl = `${acsBase}?txn=${transactionId}`; + + if (version === '1.0') { + session.pareq = buildPareq(session, termUrl); + session.status = 'challenge_required'; + } else { + // 3DS2 — if frictionless mode is forced (sandbox), skip challenge + if (sandboxFrictionless) { + session.status = 'authenticated'; + session.eci = '05'; + session.authenticationValue = generateAuthValue( + sessionId, + cfg.hmacSecret ?? DEFAULT_HMAC_SECRET, + ); + } else { + session.creq = buildCreq(session); + session.status = 'challenge_required'; + } + } + } + + sessions.set(sessionId, session); + + return { + threeDSSessionId: sessionId, + status: session.status, + version, + acsUrl: session.acsUrl, + pareq: session.pareq, + creq: session.creq, + termUrl: enrolled ? termUrl : undefined, + enrolled, + message: enrolled + ? version === '1.0' + ? 'Redirect cardholder to ACS' + : sandboxFrictionless + ? 'Frictionless authentication completed' + : 'Present 3DS2 challenge window' + : 'Card not enrolled in 3D Secure; proceed without authentication', + }; +} + +/** + * Verify the result of a completed 3DS challenge. + */ +export function verify3DS( + req: Verify3DSRequest, + cfg: ThreeDSConfig = {}, +): ThreeDSVerifyResponse { + const session = sessions.get(req.threeDSSessionId); + + if (!session) { + return { + threeDSSessionId: req.threeDSSessionId, + paymentIntentId: req.paymentIntentId, + status: 'error', + authenticated: false, + message: 'Session not found or expired', + }; + } + + if (session.paymentIntentId !== req.paymentIntentId) { + session.status = 'failed'; + session.updatedAt = now(); + sessions.set(session.sessionId, session); + return { + threeDSSessionId: session.sessionId, + paymentIntentId: req.paymentIntentId, + status: 'failed', + authenticated: false, + message: 'Payment intent mismatch', + }; + } + + if (new Date() > session.expiresAt) { + session.status = 'failed'; + session.updatedAt = now(); + sessions.set(session.sessionId, session); + return { + threeDSSessionId: session.sessionId, + paymentIntentId: session.paymentIntentId, + status: 'failed', + authenticated: false, + message: 'Session has expired', + }; + } + + // If the session was already resolved (e.g. frictionless) return current state + if (session.status === 'authenticated') { + return { + threeDSSessionId: session.sessionId, + paymentIntentId: session.paymentIntentId, + status: 'authenticated', + eci: session.eci, + authenticationValue: session.authenticationValue, + transactionId: session.transactionId, + authenticated: true, + message: 'Already authenticated', + }; + } + + // Determine the transStatus from the request + const transStatus = req.transStatus ?? (req.paRes ? 'Y' : 'N'); + const authenticated = transStatus === 'Y' || transStatus === 'A'; + + if (authenticated) { + session.status = 'authenticated'; + session.eci = deriveEci(session.version, transStatus); + session.authenticationValue = generateAuthValue( + session.sessionId, + cfg.hmacSecret ?? DEFAULT_HMAC_SECRET, + ); + } else if (transStatus === 'C') { + // Challenge still in progress — caller should not call verify yet + session.status = 'challenge_required'; + } else { + session.status = 'failed'; + } + + session.updatedAt = now(); + sessions.set(session.sessionId, session); + + return { + threeDSSessionId: session.sessionId, + paymentIntentId: session.paymentIntentId, + status: session.status, + eci: session.eci, + authenticationValue: session.authenticationValue, + transactionId: session.transactionId, + authenticated, + message: authenticated + ? '3DS authentication successful' + : `3DS authentication failed (transStatus=${transStatus})`, + }; +} + +/** + * Retrieve the current state of a 3DS session. + */ +export function get3DSSession(sessionId: string): ThreeDSSession | undefined { + return sessions.get(sessionId); +} + +/** + * Cancel an active 3DS session. + */ +export function cancel3DSSession( + sessionId: string, + reason?: string, +): { success: boolean; message: string } { + const session = sessions.get(sessionId); + if (!session) { + return { success: false, message: 'Session not found' }; + } + session.status = 'cancelled'; + session.updatedAt = now(); + sessions.set(sessionId, session); + return { + success: true, + message: reason ? `Session cancelled: ${reason}` : 'Session cancelled', + }; +} + +/** + * Remove expired sessions (useful for a scheduled cleanup job). + * Returns the number of sessions removed. + */ +export function purgeExpiredSessions(): number { + const cutoff = now(); + let removed = 0; + for (const [id, s] of sessions.entries()) { + if (s.expiresAt < cutoff) { + sessions.delete(id); + removed++; + } + } + return removed; +} + +/** + * Exposed for testing – allows pre-seeding a session. + * @internal + */ +export function _seedSession(session: ThreeDSSession): void { + sessions.set(session.sessionId, session); +} + +/** + * Exposed for testing – clears all sessions. + * @internal + */ +export function _clearSessions(): void { + sessions.clear(); +} diff --git a/backend/src/services/__tests__/3ds.test.ts b/backend/src/services/__tests__/3ds.test.ts new file mode 100644 index 00000000..19187827 --- /dev/null +++ b/backend/src/services/__tests__/3ds.test.ts @@ -0,0 +1,366 @@ +/** + * Unit tests – 3DS Authentication Service + * + * Covers: + * - initiate3DS: enrolled / not-enrolled / frictionless sandbox / 3DS1 vs 3DS2 + * - verify3DS: success (Y, A), failure (N, R), session-not-found, expired, mismatch + * - get3DSSession: present / absent + * - cancel3DSSession: success / missing session + * - purgeExpiredSessions: cleans only expired entries + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + initiate3DS, + verify3DS, + get3DSSession, + cancel3DSSession, + purgeExpiredSessions, + _clearSessions, + _seedSession, + type ThreeDSSession, +} from '../3ds.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const BASE_INITIATE = { + paymentIntentId: 'pi_test_001', + amount: 100, + currency: 'USD', + returnUrl: 'https://example.com/callback', + merchantId: 'merch_01', +}; + +/** Even-digit BINs → enrolled in our test heuristic */ +const ENROLLED_CARD = { cardNumber: '4000000000000002' }; // BIN 400000, last digit 0 → enrolled +const NOT_ENROLLED_CARD = { cardNumber: '4100000000000001' }; // BIN 410000, last digit 0 → enrolled +// Actually let's use cards that map deterministically: +// BIN 400000 ends in 0 → even → enrolled +// BIN 510000 ends in 0 → even → enrolled (Mastercard → 3DS2.2) +// BIN 411111 ends in 1 → odd → NOT enrolled +const ENROLLED_VISA = { cardNumber: '4000001111111119' }; // BIN 400000 → enrolled, Visa → 2.1 +const ENROLLED_MC = { cardNumber: '5100001111111119' }; // BIN 510000 → enrolled, MC → 2.2 +const NOT_ENROLLED_VISA = { cardNumber: '4111111111111111' }; // BIN 411111, last '1' → odd → not enrolled + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + _clearSessions(); +}); + +// ── initiate3DS ─────────────────────────────────────────────────────────────── + +describe('initiate3DS', () => { + it('returns a valid session for an enrolled Visa card (3DS 2.1)', () => { + const result = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + + expect(result.threeDSSessionId).toBeTruthy(); + expect(result.enrolled).toBe(true); + expect(result.status).toBe('challenge_required'); + expect(result.version).toBe('2.1'); + expect(result.acsUrl).toContain('acs.example.com'); + expect(result.creq).toBeTruthy(); + expect(result.pareq).toBeUndefined(); + }); + + it('returns a valid session for an enrolled Mastercard (3DS 2.2)', () => { + const result = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_MC }); + + expect(result.enrolled).toBe(true); + expect(result.version).toBe('2.2'); + expect(result.status).toBe('challenge_required'); + }); + + it('handles not-enrolled cards gracefully', () => { + const result = initiate3DS({ ...BASE_INITIATE, ...NOT_ENROLLED_VISA }); + + expect(result.enrolled).toBe(false); + expect(result.status).toBe('not_enrolled'); + expect(result.acsUrl).toBeUndefined(); + expect(result.creq).toBeUndefined(); + }); + + it('returns frictionless status when sandboxFrictionless=true', () => { + const result = initiate3DS( + { ...BASE_INITIATE, ...ENROLLED_VISA }, + { sandboxFrictionless: true }, + ); + + expect(result.status).toBe('authenticated'); + expect(result.enrolled).toBe(true); + }); + + it('accepts a card token and extracts BIN correctly', () => { + // tok__ → BIN 400000 → enrolled + const result = initiate3DS({ ...BASE_INITIATE, cardToken: 'tok_400000_xyz' }); + + expect(result.enrolled).toBe(true); + expect(result.threeDSSessionId).toBeTruthy(); + }); + + it('accepts a bare cardBin', () => { + const result = initiate3DS({ ...BASE_INITIATE, cardBin: '400000' }); + + expect(result.enrolled).toBe(true); + }); + + it('uses legacy 1.0 flow for non-Visa/Mastercard BINs', () => { + // BIN 370000 → prefix 3700 < 4000 → version 1.0 + const result = initiate3DS({ ...BASE_INITIATE, cardBin: '370000' }); + + if (result.enrolled) { + expect(result.version).toBe('1.0'); + expect(result.pareq).toBeTruthy(); + expect(result.termUrl).toBeTruthy(); + } + // Whether enrolled depends on last digit of BIN; either path is valid + expect(result.threeDSSessionId).toBeTruthy(); + }); + + it('stores the session so it can be retrieved later', () => { + const result = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + + const stored = get3DSSession(result.threeDSSessionId); + expect(stored).toBeDefined(); + expect(stored!.paymentIntentId).toBe(BASE_INITIATE.paymentIntentId); + }); +}); + +// ── verify3DS ───────────────────────────────────────────────────────────────── + +describe('verify3DS', () => { + it('authenticates when transStatus=Y', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const result = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + transStatus: 'Y', + }); + + expect(result.authenticated).toBe(true); + expect(result.status).toBe('authenticated'); + expect(result.eci).toBe('05'); + expect(result.authenticationValue).toBeTruthy(); + }); + + it('authenticates when transStatus=A (attempted)', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const result = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + transStatus: 'A', + }); + + expect(result.authenticated).toBe(true); + expect(result.eci).toBe('06'); + }); + + it('fails when transStatus=N', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const result = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + transStatus: 'N', + }); + + expect(result.authenticated).toBe(false); + expect(result.status).toBe('failed'); + }); + + it('fails when transStatus=R (rejected)', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const result = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + transStatus: 'R', + }); + + expect(result.authenticated).toBe(false); + }); + + it('infers transStatus from PARes presence (3DS1 style)', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + // Provide a paRes string but no transStatus → should treat as 'Y' + const result = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + paRes: 'BASE64_PARES_DATA', + }); + + expect(result.authenticated).toBe(true); + }); + + it('returns error status for unknown session', () => { + const result = verify3DS({ + threeDSSessionId: 'nonexistent-session-id', + paymentIntentId: 'pi_123', + transStatus: 'Y', + }); + + expect(result.status).toBe('error'); + expect(result.authenticated).toBe(false); + }); + + it('returns failure when paymentIntentId does not match', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const result = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: 'pi_different', + transStatus: 'Y', + }); + + expect(result.authenticated).toBe(false); + expect(result.status).toBe('failed'); + }); + + it('returns the existing result for an already-authenticated session', () => { + const init = initiate3DS( + { ...BASE_INITIATE, ...ENROLLED_VISA }, + { sandboxFrictionless: true }, + ); + + // Session is already authenticated (frictionless) + const result = verify3DS({ + threeDSSessionId: init.threeDSSessionId, + paymentIntentId: BASE_INITIATE.paymentIntentId, + transStatus: 'Y', + }); + + expect(result.authenticated).toBe(true); + expect(result.status).toBe('authenticated'); + }); + + it('marks session as expired when expiresAt is in the past', () => { + // Seed a session that has already expired + const sessionId = 'expired-session'; + _seedSession({ + sessionId, + paymentIntentId: 'pi_expired', + merchantId: 'merch_01', + amount: 50, + currency: 'USD', + status: 'challenge_required', + version: '2.1', + enrolled: true, + createdAt: new Date(Date.now() - 1_000_000), + updatedAt: new Date(Date.now() - 1_000_000), + expiresAt: new Date(Date.now() - 1000), // already expired + }); + + const result = verify3DS({ + threeDSSessionId: sessionId, + paymentIntentId: 'pi_expired', + transStatus: 'Y', + }); + + expect(result.authenticated).toBe(false); + expect(result.status).toBe('failed'); + expect(result.message).toContain('expired'); + }); +}); + +// ── get3DSSession ───────────────────────────────────────────────────────────── + +describe('get3DSSession', () => { + it('returns undefined for unknown session ID', () => { + expect(get3DSSession('unknown')).toBeUndefined(); + }); + + it('returns the session after initiation', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const session = get3DSSession(init.threeDSSessionId); + + expect(session).toBeDefined(); + expect(session!.sessionId).toBe(init.threeDSSessionId); + expect(session!.amount).toBe(BASE_INITIATE.amount); + expect(session!.currency).toBe(BASE_INITIATE.currency); + }); +}); + +// ── cancel3DSSession ────────────────────────────────────────────────────────── + +describe('cancel3DSSession', () => { + it('returns success=false for an unknown session', () => { + const result = cancel3DSSession('nonexistent'); + + expect(result.success).toBe(false); + }); + + it('cancels an active session', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const result = cancel3DSSession(init.threeDSSessionId, 'User abandoned flow'); + + expect(result.success).toBe(true); + expect(result.message).toContain('User abandoned flow'); + + const session = get3DSSession(init.threeDSSessionId); + expect(session!.status).toBe('cancelled'); + }); + + it('cancels without a reason string', () => { + const init = initiate3DS({ ...BASE_INITIATE, ...ENROLLED_VISA }); + const result = cancel3DSSession(init.threeDSSessionId); + + expect(result.success).toBe(true); + expect(result.message).toBe('Session cancelled'); + }); +}); + +// ── purgeExpiredSessions ────────────────────────────────────────────────────── + +describe('purgeExpiredSessions', () => { + it('removes only expired sessions and returns the count', () => { + // Seed one expired and one active session + _seedSession({ + sessionId: 'sess-expired', + paymentIntentId: 'pi_1', + merchantId: 'm1', + amount: 10, + currency: 'USD', + status: 'pending', + version: '2.1', + enrolled: true, + createdAt: new Date(Date.now() - 2_000_000), + updatedAt: new Date(Date.now() - 2_000_000), + expiresAt: new Date(Date.now() - 1000), + }); + + _seedSession({ + sessionId: 'sess-active', + paymentIntentId: 'pi_2', + merchantId: 'm1', + amount: 20, + currency: 'USD', + status: 'challenge_required', + version: '2.1', + enrolled: true, + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(Date.now() + 900_000), + }); + + const removed = purgeExpiredSessions(); + + expect(removed).toBe(1); + expect(get3DSSession('sess-expired')).toBeUndefined(); + expect(get3DSSession('sess-active')).toBeDefined(); + }); + + it('returns 0 when there are no expired sessions', () => { + _seedSession({ + sessionId: 'sess-ok', + paymentIntentId: 'pi_3', + merchantId: 'm1', + amount: 5, + currency: 'EUR', + status: 'pending', + version: '2.1', + enrolled: true, + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(Date.now() + 900_000), + }); + + expect(purgeExpiredSessions()).toBe(0); + }); +});