From 7bc19bfe427801ea24afdecbb34f402c7350013a Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:02:38 +0200 Subject: [PATCH 1/2] feat(checkout): expire payments tokens after one hour --- src/constants.ts | 1 + src/controller/object-storage.controller.ts | 7 ++-- src/utils/signUserToken.ts | 3 +- .../controller/checkout.controller.test.ts | 15 ++++++++ .../controller/payments.controller.test.ts | 36 ++++++++++++++++++- tests/src/fixtures.ts | 8 ++++- tests/src/utils/signUserToken.test.ts | 8 +++++ 7 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 0c7f90f8..8f1d6868 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,3 +35,4 @@ export const EU_COUNTRIES = [ 'SE', ]; export const STRIPE_API_VERSION = '2025-02-24.acacia'; +export const PAYMENTS_TOKEN_EXPIRATION = '1h'; diff --git a/src/controller/object-storage.controller.ts b/src/controller/object-storage.controller.ts index c17fc074..1be5e2fd 100644 --- a/src/controller/object-storage.controller.ts +++ b/src/controller/object-storage.controller.ts @@ -8,10 +8,7 @@ import config from '../config'; import Stripe from 'stripe'; import { setupAuth } from '../plugins/auth'; import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; - -function signUserToken(customerId: string) { - return jwt.sign({ customerId }, config.JWT_SECRET); -} +import { signUserToken } from '../utils/signUserToken'; export function objectStorageController(paymentService: PaymentService) { return async function (fastify: FastifyInstance) { @@ -74,7 +71,7 @@ export function objectStorageController(paymentService: PaymentService) { customerId = id; } - return res.send({ customerId, token: signUserToken(customerId) }); + return res.send({ customerId, token: signUserToken({ customerId }) }); }, ); diff --git a/src/utils/signUserToken.ts b/src/utils/signUserToken.ts index 954deb01..d49a447e 100644 --- a/src/utils/signUserToken.ts +++ b/src/utils/signUserToken.ts @@ -1,6 +1,7 @@ import jwt from 'jsonwebtoken'; import config from '../config'; +import { PAYMENTS_TOKEN_EXPIRATION } from '../constants'; export function signUserToken(payload: { customerId?: string; invoiceId?: string }): string { - return jwt.sign(payload, config.JWT_SECRET); + return jwt.sign(payload, config.JWT_SECRET, { expiresIn: PAYMENTS_TOKEN_EXPIRATION }); } diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 000a903d..643920fb 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -3,6 +3,7 @@ import { getCreateSubscriptionResponse, getCryptoCurrency, getCustomer, + getExpiredUserToken, getInvoice, getPrice, getRawCryptoInvoiceResponse, @@ -1068,6 +1069,20 @@ describe('Checkout controller', () => { }); describe('Verify the crypto payment', () => { + test('When the payment token has expired, then the verification is rejected', async () => { + const verifyCryptoPaymentSpy = jest.spyOn(PaymentService.prototype, 'verifyCryptoPayment'); + + const response = await app.inject({ + path: '/checkout/crypto/verify/payment', + method: 'POST', + body: { token: getExpiredUserToken({ invoiceId: 'in_123' }) }, + headers: { authorization: `Bearer ${getValidAuthToken(getUser().uuid)}` }, + }); + + expect(response.statusCode).toBe(403); + expect(verifyCryptoPaymentSpy).not.toHaveBeenCalled(); + }); + test('When the crypto payment invoice has an status of paid, then true is returned indicating the invoice has been paid', async () => { const mockedUser = getUser(); const userAuthToken = getValidAuthToken(mockedUser.uuid); diff --git a/tests/src/controller/payments.controller.test.ts b/tests/src/controller/payments.controller.test.ts index e38cd375..89bd3ee0 100644 --- a/tests/src/controller/payments.controller.test.ts +++ b/tests/src/controller/payments.controller.test.ts @@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken'; import { getCreateSubscriptionResponse, getCustomer, + getExpiredUserToken, getLicenseCode, getPaymentIntent, getUniqueCodes, @@ -279,7 +280,7 @@ describe('Payment controller e2e tests', () => { expect(response.statusCode).toBe(200); expect(responseBody).toStrictEqual({ customerId: mockedCustomer.id, - token: jwt.sign({ customerId: mockedCustomer.id }, config.JWT_SECRET), + token: getValidUserToken({ customerId: mockedCustomer.id }), }); expect(attachVatIdSpy).toHaveBeenCalled(); expect(attachVatIdSpy).toHaveBeenCalledWith(mockedCustomer.id, mockedCustomer.address?.country, companyVatId); @@ -364,6 +365,22 @@ describe('Payment controller e2e tests', () => { expect(responseBody).toStrictEqual(subResponse); }); + it('When the user token has expired, then the subscription is rejected', async () => { + const mockedUser = getUser(); + + const response = await app.inject({ + method: 'POST', + path: '/object-storage/subscription', + body: { + customerId: mockedUser.customerId, + priceId: 'price_id', + token: getExpiredUserToken({ customerId: mockedUser.customerId }), + }, + }); + + expect(response.statusCode).toBe(403); + }); + it('When the user token is not provided, then an error indicating so is thrown', async () => { const mockedUser = getUser(); const subResponse = getCreateSubscriptionResponse(); @@ -484,6 +501,23 @@ describe('Payment controller e2e tests', () => { }); }); + it('When the user token has expired, then the verification is rejected', async () => { + const mockedUser = getUser(); + + const response = await app.inject({ + method: 'POST', + path: '/payment-method-verification', + body: { + customerId: mockedUser.customerId, + token: getExpiredUserToken({ customerId: mockedUser.customerId }), + paymentMethod: 'pm_123', + priceId: 'price_id', + }, + }); + + expect(response.statusCode).toBe(403); + }); + it('When the customer ID from the user token does not match with the real user customer ID, then an error indicating so is thrown', async () => { const mockedUser = getUser(); const priceId = 'price_id'; diff --git a/tests/src/fixtures.ts b/tests/src/fixtures.ts index e2bccf83..29677a6f 100644 --- a/tests/src/fixtures.ts +++ b/tests/src/fixtures.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import { FastifyBaseLogger } from 'fastify'; import { Chance } from 'chance'; import config from '../../src/config'; +import { signUserToken } from '../../src/utils/signUserToken'; import { User, UserSubscription, UserType } from '../../src/core/users/User'; import { PaymentIntent, PaymentIntentCrypto, PaymentIntentFiat, PromotionCode } from '../../src/types/payment'; import { RenewalPeriod, SubscriptionCreated } from '../../src/types/subscription'; @@ -132,7 +133,12 @@ export const getValidGatewayToken = () => { }; export const getValidUserToken = (payload: { customerId?: string; invoiceId?: string }): string => { - return jwt.sign(payload, config.JWT_SECRET); + return signUserToken(payload); +}; + +export const getExpiredUserToken = (payload: { customerId?: string; invoiceId?: string }): string => { + const twoHoursAgoInSeconds = Math.floor(Date.now() / 1000) - 2 * 60 * 60; + return jwt.sign({ ...payload, iat: twoHoursAgoInSeconds, exp: twoHoursAgoInSeconds + 60 * 60 }, config.JWT_SECRET); }; export const getCustomer = (params?: Partial): Stripe.Customer => { diff --git a/tests/src/utils/signUserToken.test.ts b/tests/src/utils/signUserToken.test.ts index 6e829e6f..88ff7cb6 100644 --- a/tests/src/utils/signUserToken.test.ts +++ b/tests/src/utils/signUserToken.test.ts @@ -20,4 +20,12 @@ describe('Signing user token', () => { expect(decodedToken.invoiceId).toBe(invoiceId); }); + + test('When a token is signed, then it expires one hour later', () => { + const token = signUserToken({ customerId: 'cus_123' }); + + const { iat, exp } = jwt.verify(token, config.JWT_SECRET) as { iat: number; exp: number }; + + expect(exp - iat).toBe(60 * 60); + }); }); From 33448427c2226fe8cf9318ed97c0db87c7bda1f8 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:56:42 +0200 Subject: [PATCH 2/2] fix(checkout): extend payment token expiration to 6 hours for crypto payments --- src/constants.ts | 2 +- tests/src/utils/signUserToken.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 8f1d6868..48a5e322 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,4 +35,4 @@ export const EU_COUNTRIES = [ 'SE', ]; export const STRIPE_API_VERSION = '2025-02-24.acacia'; -export const PAYMENTS_TOKEN_EXPIRATION = '1h'; +export const PAYMENTS_TOKEN_EXPIRATION = '6h'; diff --git a/tests/src/utils/signUserToken.test.ts b/tests/src/utils/signUserToken.test.ts index 88ff7cb6..d894fe16 100644 --- a/tests/src/utils/signUserToken.test.ts +++ b/tests/src/utils/signUserToken.test.ts @@ -21,11 +21,11 @@ describe('Signing user token', () => { expect(decodedToken.invoiceId).toBe(invoiceId); }); - test('When a token is signed, then it expires one hour later', () => { + test('When a token is signed, then it expires six hours later', () => { const token = signUserToken({ customerId: 'cus_123' }); const { iat, exp } = jwt.verify(token, config.JWT_SECRET) as { iat: number; exp: number }; - expect(exp - iat).toBe(60 * 60); + expect(exp - iat).toBe(6 * 60 * 60); }); });