Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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());
});
Expand Down
133 changes: 133 additions & 0 deletions backend/src/routes/3ds.ts
Original file line number Diff line number Diff line change
@@ -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 });
}),
);
248 changes: 248 additions & 0 deletions backend/src/routes/__tests__/3ds.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading