feat(next): attempt no prompt auth on landing

Because:

* Attempt to perform a no prompt authentication request to FxA when
  customer is first navigated to Payment Next

This commit:

* Adds a landing route to initiate the no prompt signin
* If user is authenticated, add FxA uid to cart
* Add signin error page to handle FxA error for unauthenticated users
* Add placeholder generic error page.

Closes #FXA-7523
This commit is contained in:
Reino Muhl 2024-04-26 07:43:26 -04:00
Родитель c06042e709
Коммит 888ccaad90
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: C86660FCF998897A
9 изменённых файлов: 184 добавлений и 25 удалений

Просмотреть файл

@ -0,0 +1,53 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { signIn } from 'apps/payments/next/auth';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
export const dynamic = 'force-dynamic'; // defaults to auto
/**
* This landing route will initiate the OAuth no prompt signin
* attempt with FxA.
*
* Only when an unexpected error occurs, will the user be redirected
* to a generic error page.
*
* On successful authentication, the customer will be redirected
* to the /new page in a "Signed In" state. (i.e. FxA uid added to cart)
*
* On failure the customer will be redirected to /error/auth/signin
* where the error will be handled correctly, and ideally redirect
* the customer to the /new page in a "Signed Out" state. (i.e. no
* FxA uid added to the cart)
*
* This needs to be a route handler, since the `signIn` server
* action needs to be executed from a route handler.
*/
export async function GET(request: NextRequest) {
// Replace 'landing' in the URL with 'new'
const redirectToUrl = request.nextUrl.clone();
redirectToUrl.pathname = redirectToUrl.pathname.replace('/landing', '/new');
let redirectUrl;
try {
redirectUrl = await signIn(
'fxa',
{
redirect: false,
redirectTo: redirectToUrl.href,
},
{ prompt: 'none' }
);
} catch (error) {
// Log the error and redirect to /new without attempting signIn
console.error(error);
}
if (!redirectUrl) {
redirectUrl = redirectToUrl.href;
}
redirect(redirectUrl);
}

Просмотреть файл

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { setupCartAction } from '@fxa/payments/ui/server'; import { setupCartAction } from '@fxa/payments/ui/server';
import { auth } from 'apps/payments/next/auth';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@ -14,19 +15,30 @@ interface CheckoutNewParams {
export default async function CheckoutNew({ export default async function CheckoutNew({
params, params,
searchParams,
}: { }: {
params: CheckoutNewParams; params: CheckoutNewParams;
searchParams: Record<string, string>;
}) { }) {
const { offeringId, interval } = params; const { offeringId, interval } = params;
const session = await auth();
const fxaUid = session?.user?.id;
const ip = (headers().get('x-forwarded-for') ?? '127.0.0.1').split(',')[0]; const ip = (headers().get('x-forwarded-for') ?? '127.0.0.1').split(',')[0];
const { id: cartId } = await setupCartAction( const { id: cartId } = await setupCartAction(
interval, interval,
offeringId, offeringId,
undefined, undefined,
undefined, undefined,
undefined, fxaUid,
ip ip
); );
const searchParamsString = new URLSearchParams(searchParams).toString();
if (searchParamsString) {
redirect(`${cartId}/start?${searchParamsString}`);
} else {
redirect(`${cartId}/start`); redirect(`${cartId}/start`);
}
} }

Просмотреть файл

@ -0,0 +1,42 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
interface ErrorAuthSigninSearchParams {
error?: string;
}
/**
* Handle Errors that might occur during Auth SignIn
*
* Most common error would be OAuthCallbackError. This error is expected if
* no prompt signin is attempted on a user thats not currently signed into FxA.
*
* In situations like this, ideally the customer should be redirected to the
* appropriate checkout page in a "signed out" state. To achieve this the
* authjs.callback-url cookie is read to retrieve the correct redirect url,
* otherwise it's not possible to know what offering and interval the customer
* was attempting to reach.
*
*/
export default async function Error({
searchParams,
}: {
searchParams: ErrorAuthSigninSearchParams;
}) {
const fallbackErrorPagePath = '/error';
if (searchParams?.error !== 'OAuthCallbackError') {
redirect(fallbackErrorPagePath);
}
const cookieStore = cookies();
const redirectUrl = cookieStore.get('authjs.callback-url');
if (redirectUrl?.value) {
redirect(`${redirectUrl.value}`);
} else {
redirect(fallbackErrorPagePath);
}
}

Просмотреть файл

@ -0,0 +1,6 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export default function ErrorGeneric() {
return <div>Generic Error Page</div>;
}

Просмотреть файл

@ -12,7 +12,30 @@ export default function Index() {
// This page will be fixed before launch by FXA-8304 // This page will be fixed before launch by FXA-8304
return ( return (
<div> <div>
<h1 className="text-center m-4">Welcome</h1> <h1 className="text-xxl text-center m-4">Welcome</h1>
<div className="flex-col">
<h2 className="text-xl">With auth</h2>
<div className="flex gap-8">
<div className="flex flex-col gap-2 p-4 items-center">
<h2>123Done - Monthly</h2>
<Link
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
href="/en/123done/checkout/monthly/landing"
>
Redirect
</Link>
</div>
<div className="flex flex-col gap-2 p-4 items-center">
<h2>123Done - Yearly</h2>
<Link
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
href="/en/123done/checkout/yearly/landing"
>
Redirect
</Link>
</div>
</div>
<h2 className="text-xl mt-8">Without auth</h2>
<div className="flex gap-8"> <div className="flex gap-8">
<div className="flex flex-col gap-2 p-4 items-center"> <div className="flex flex-col gap-2 p-4 items-center">
<h2>123Done - Monthly</h2> <h2>123Done - Monthly</h2>
@ -34,5 +57,6 @@ export default function Index() {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

Просмотреть файл

@ -6,4 +6,7 @@ import type { NextAuthConfig } from 'next-auth';
export const authConfig = { export const authConfig = {
providers: [], // Add providers with an empty array for now providers: [], // Add providers with an empty array for now
pages: {
signIn: '/en-US/error/auth/signin',
},
} satisfies NextAuthConfig; } satisfies NextAuthConfig;

Просмотреть файл

@ -30,8 +30,15 @@ export const {
], ],
callbacks: { callbacks: {
async session(params: any) { async session(params: any) {
params.session.user.id = params.token.sub; params.session.user.id = params.token.fxaUid;
return params.session; return params.session;
}, },
async jwt({ token, account }) {
const fxaUid = token.fxaUid || account?.providerAccountId;
return {
...token,
fxaUid,
};
},
}, },
}); });

Просмотреть файл

@ -10,7 +10,10 @@ import { CartManager } from './cart.manager';
import { ResultCart, TaxAddress, UpdateCart } from './cart.types'; import { ResultCart, TaxAddress, UpdateCart } from './cart.types';
import { handleEligibilityStatusMap } from './cart.utils'; import { handleEligibilityStatusMap } from './cart.utils';
import { CartErrorReasonId, CartState } from '@fxa/shared/db/mysql/account'; import { CartErrorReasonId, CartState } from '@fxa/shared/db/mysql/account';
import { AccountCustomerManager } from '@fxa/payments/stripe'; import {
AccountCustomerManager,
AccountCustomerNotFoundError,
} from '@fxa/payments/stripe';
import { GeoDBManager } from '@fxa/shared/geodb'; import { GeoDBManager } from '@fxa/shared/geodb';
import { CheckoutService } from './checkout.service'; import { CheckoutService } from './checkout.service';
@ -42,9 +45,17 @@ export class CartService {
// - Check if user is eligible to subscribe to plan, else throw error // - Check if user is eligible to subscribe to plan, else throw error
// - Fetch invoice preview total from Stripe for `amount` // - Fetch invoice preview total from Stripe for `amount`
// - Fetch stripeCustomerId if uid is passed and has a customer id // - Fetch stripeCustomerId if uid is passed and has a customer id
const accountCustomer = args.uid let accountCustomer;
? await this.accountCustomerManager.getAccountCustomerByUid(args.uid) if (args.uid) {
: undefined; try {
accountCustomer =
await this.accountCustomerManager.getAccountCustomerByUid(args.uid);
} catch (error) {
if (!(error instanceof AccountCustomerNotFoundError)) {
throw error;
}
}
}
const taxAddress = args.ip const taxAddress = args.ip
? this.geodbManager.getTaxAddress(args.ip) ? this.geodbManager.getTaxAddress(args.ip)

Просмотреть файл

@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export * from './lib/accountCustomer/accountCustomer.error';
export * from './lib/accountCustomer/accountCustomer.factories'; export * from './lib/accountCustomer/accountCustomer.factories';
export * from './lib/accountCustomer/accountCustomer.manager'; export * from './lib/accountCustomer/accountCustomer.manager';
export { export {