feat(payments-next): Add redirects on mismatched cart uids

Because:

* Users can currently view carts associated with another account's uid when provided with the URL

This commit:

* Adds in a frontend intercept that redirects the user to the start of the checkout process if the request cart's UID does not match the signed in user's UID

Closes #PAY-3155
This commit is contained in:
Davey Alvarez 2025-12-18 12:44:10 -08:00
parent 866e34d928
commit f88ca40d9c
No known key found for this signature in database
GPG Key ID: A538D290868DCC59
6 changed files with 159 additions and 17 deletions

View File

@ -5,7 +5,7 @@
import { headers } from 'next/headers';
import Image from 'next/image';
import Link from 'next/link';
import { auth } from 'apps/payments/next/auth';
import errorIcon from '@fxa/shared/assets/images/error.svg';
import checkIcon from '@fxa/shared/assets/images/check.svg';
import {
@ -15,12 +15,12 @@ import {
getErrorFtlInfo,
buildPageMetadata,
} from '@fxa/payments/ui/server';
import {
getCartOrRedirectAction,
} from '@fxa/payments/ui/actions';
import { getCartOrRedirectAction } from '@fxa/payments/ui/actions';
import { config } from 'apps/payments/next/config';
import type { Metadata } from 'next';
import { CartErrorReasonId } from '@fxa/shared/db/mysql/account';
import { buildRedirectUrl } from '@fxa/payments/ui';
import { redirect } from 'next/navigation';
// forces dynamic rendering
// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
@ -53,15 +53,40 @@ export default async function CheckoutError({
const { locale } = params;
const acceptLanguage = headers().get('accept-language');
const sessionPromise = auth();
const cartPromise = getCartOrRedirectAction(
params.cartId,
SupportedPages.ERROR,
searchParams
);
const l10n = getApp().getL10n(acceptLanguage, locale);
const [cart] = await Promise.all([cartPromise]);
const [cart, session] = await Promise.all([cartPromise, sessionPromise]);
const errorReason = getErrorFtlInfo(cart.errorReasonId, params, config, searchParams);
const errorReason = getErrorFtlInfo(
cart.errorReasonId,
params,
config,
searchParams
);
if (cart.id && cart.uid !== session?.user?.id) {
const redirectSearchParams: Record<string, string | string[]> =
searchParams || {};
delete redirectSearchParams.cartId;
delete redirectSearchParams.cartVersion;
const redirectTo = buildRedirectUrl(
params.offeringId,
params.interval,
'new',
'checkout',
{
baseUrl: config.paymentsNextHostedUrl,
locale,
searchParams: redirectSearchParams,
}
);
redirect(redirectTo);
}
return (
<>
@ -72,7 +97,7 @@ export default async function CheckoutError({
{
// Once more conditionals are added, move this to a separate component
cart.errorReasonId ===
CartErrorReasonId.CART_ELIGIBILITY_STATUS_SAME ? (
CartErrorReasonId.CART_ELIGIBILITY_STATUS_SAME ? (
<Image
src={checkIcon}
alt=""

View File

@ -7,6 +7,7 @@ import {
LoadingSpinner,
StripeWrapper,
PaymentInputHandler,
buildRedirectUrl,
} from '@fxa/payments/ui';
import {
getApp,
@ -17,6 +18,8 @@ import { headers } from 'next/headers';
import { getCartOrRedirectAction } from '@fxa/payments/ui/actions';
import type { Metadata } from 'next';
import { config } from 'apps/payments/next/config';
import { auth } from 'apps/payments/next/auth';
import { redirect } from 'next/navigation';
export async function generateMetadata({
params,
@ -45,14 +48,38 @@ export default async function NeedsInputPage({
const { locale } = params;
const acceptLanguage = headers().get('accept-language');
const l10n = getApp().getL10n(acceptLanguage, locale);
const cart = await getCartOrRedirectAction(
const sessionPromise = auth();
const cartPromise = getCartOrRedirectAction(
params.cartId,
SupportedPages.NEEDS_INPUT,
searchParams
);
const [session, cart] = await Promise.all([sessionPromise, cartPromise]);
if (!cart.currency) {
throw new Error('Currency is missing from the cart');
}
if (!session?.user?.id ||cart.uid !== session.user.id) {
const redirectSearchParams: Record<string, string | string[]> =
searchParams || {};
delete redirectSearchParams.cartId;
delete redirectSearchParams.cartVersion;
const redirectTo = buildRedirectUrl(
params.offeringId,
params.interval,
'new',
'checkout',
{
baseUrl: config.paymentsNextHostedUrl,
locale,
searchParams: redirectSearchParams,
}
);
redirect(redirectTo);
}
return (
<section
className="flex flex-col text-center text-sm"

View File

@ -8,7 +8,7 @@ import Image from 'next/image';
import { auth } from 'apps/payments/next/auth';
import { SubPlatPaymentMethodType } from '@fxa/payments/customer';
import { getCardIcon } from '@fxa/payments/ui';
import { buildRedirectUrl, getCardIcon } from '@fxa/payments/ui';
import {
fetchCMSData,
getCartOrRedirectAction,
@ -20,6 +20,7 @@ import {
buildPageMetadata,
} from '@fxa/payments/ui/server';
import { config } from 'apps/payments/next/config';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
@ -68,6 +69,25 @@ export default async function CheckoutSuccess({
sessionPromise,
]);
if (!session?.user?.id || cart.uid !== session.user.id) {
const redirectSearchParams: Record<string, string | string[]> =
searchParams || {};
delete redirectSearchParams.cartId;
delete redirectSearchParams.cartVersion;
const redirectTo = buildRedirectUrl(
params.offeringId,
params.interval,
'new',
'checkout',
{
baseUrl: config.paymentsNextHostedUrl,
locale,
searchParams: redirectSearchParams,
}
);
redirect(redirectTo);
}
const { successActionButtonUrl, successActionButtonLabel } =
cms.commonContent.localizations.at(0) || cms.commonContent;

View File

@ -5,7 +5,7 @@
import { headers } from 'next/headers';
import Image from 'next/image';
import Link from 'next/link';
import { auth } from 'apps/payments/next/auth';
import errorIcon from '@fxa/shared/assets/images/error.svg';
import {
getApp,
@ -14,11 +14,11 @@ import {
getErrorFtlInfo,
buildPageMetadata,
} from '@fxa/payments/ui/server';
import {
getCartOrRedirectAction,
} from '@fxa/payments/ui/actions';
import { getCartOrRedirectAction } from '@fxa/payments/ui/actions';
import { config } from 'apps/payments/next/config';
import { Metadata } from 'next';
import { buildRedirectUrl } from '@fxa/payments/ui';
import { redirect } from 'next/navigation';
// forces dynamic rendering
// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
@ -51,15 +51,40 @@ export default async function UpgradeError({
const { locale } = params;
const acceptLanguage = headers().get('accept-language');
const sessionPromise = auth();
const cartPromise = getCartOrRedirectAction(
params.cartId,
SupportedPages.ERROR,
searchParams
);
const l10n = getApp().getL10n(acceptLanguage, locale);
const [cart] = await Promise.all([cartPromise]);
const [cart, session] = await Promise.all([cartPromise, sessionPromise]);
const errorReason = getErrorFtlInfo(cart.errorReasonId, params, config, searchParams);
const errorReason = getErrorFtlInfo(
cart.errorReasonId,
params,
config,
searchParams
);
if (cart.id && cart.uid !== session?.user?.id) {
const redirectSearchParams: Record<string, string | string[]> =
searchParams || {};
delete redirectSearchParams.cartId;
delete redirectSearchParams.cartVersion;
const redirectTo = buildRedirectUrl(
params.offeringId,
params.interval,
'new',
'checkout',
{
baseUrl: config.paymentsNextHostedUrl,
locale,
searchParams: redirectSearchParams,
}
);
redirect(redirectTo);
}
return (
<>

View File

@ -7,6 +7,7 @@ import {
LoadingSpinner,
StripeWrapper,
PaymentInputHandler,
buildRedirectUrl,
} from '@fxa/payments/ui';
import {
getApp,
@ -17,6 +18,8 @@ import { headers } from 'next/headers';
import { getCartOrRedirectAction } from '@fxa/payments/ui/actions';
import { Metadata } from 'next';
import { config } from 'apps/payments/next/config';
import { auth } from 'apps/payments/next/auth';
import { redirect } from 'next/navigation';
export async function generateMetadata({
params,
@ -45,14 +48,36 @@ export default async function NeedsInputPage({
const { locale } = params;
const acceptLanguage = headers().get('accept-language');
const l10n = getApp().getL10n(acceptLanguage, locale);
const cart = await getCartOrRedirectAction(
const cartPromise = getCartOrRedirectAction(
params.cartId,
SupportedPages.NEEDS_INPUT,
searchParams
);
const sessionPromise = auth();
const [session, cart] = await Promise.all([sessionPromise, cartPromise]);
if (!cart.currency) {
throw new Error('Currency is missing from the cart');
}
if (!session?.user?.id || cart.uid !== session.user.id) {
const redirectSearchParams: Record<string, string | string[]> =
searchParams || {};
delete redirectSearchParams.cartId;
delete redirectSearchParams.cartVersion;
const redirectTo = buildRedirectUrl(
params.offeringId,
params.interval,
'new',
'checkout',
{
baseUrl: config.paymentsNextHostedUrl,
locale,
searchParams: redirectSearchParams,
}
);
redirect(redirectTo);
}
return (
<section
className="flex flex-col text-center text-sm"

View File

@ -8,7 +8,7 @@ import { Metadata } from 'next';
import { auth } from 'apps/payments/next/auth';
import { SubPlatPaymentMethodType } from '@fxa/payments/customer';
import { getCardIcon } from '@fxa/payments/ui';
import { buildRedirectUrl, getCardIcon } from '@fxa/payments/ui';
import {
fetchCMSData,
getCartOrRedirectAction,
@ -20,6 +20,7 @@ import {
buildPageMetadata,
} from '@fxa/payments/ui/server';
import { config } from 'apps/payments/next/config';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
@ -68,6 +69,25 @@ export default async function UpgradeSuccess({
sessionPromise,
]);
if (!session?.user?.id || cart.uid !== session.user.id) {
const redirectSearchParams: Record<string, string | string[]> =
searchParams || {};
delete redirectSearchParams.cartId;
delete redirectSearchParams.cartVersion;
const redirectTo = buildRedirectUrl(
params.offeringId,
params.interval,
'new',
'checkout',
{
baseUrl: config.paymentsNextHostedUrl,
locale,
searchParams: redirectSearchParams,
}
);
redirect(redirectTo);
}
const { successActionButtonUrl, successActionButtonLabel } =
cms.commonContent.localizations.at(0) || cms.commonContent;