Skip to content

feat(core): add session-sync endpoint #2108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: canary
Choose a base branch
from
Draft
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
5 changes: 5 additions & 0 deletions .changeset/lazy-plants-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

Adds a session sync endpoint to help sync customer sessions between hosts. The primary use-case is syncing the session between redirected checkout and Catalyst.
183 changes: 183 additions & 0 deletions core/app/api/session-sync/[jwt]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

import { signIn, signOut } from '~/auth';
import { buildConfig } from '~/build-config/reader';
import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { revalidate } from '~/client/revalidate-target';
import { clearCartId, setCartId } from '~/lib/cart';

const ChannelSpecificUrlSettingsQuery = graphql(`
query ChannelSpecificUrlSettingsQuery {
site {
settings {
url {
channelSpecificCheckoutUrl: checkoutUrl
channelSpecificVanityUrl: vanityUrl
}
}
}
}
`);

const VerifySessionSyncJwtMutation = graphql(`
mutation VerifySessionSyncJwt($jwt: String!) {
validateSessionSyncJwt(jwt: $jwt) {
content {
redirectTo
customer {
entityId
firstName
lastName
email
}
customerAccessToken {
value
expiresAt
}
cart {
entityId
}
}
errors {
__typename
... on InvalidSessionSyncJwtError {
errorType
message
}
... on JwtTokenExpiredError {
message
}
}
}
}
`);

const VALID_ORIGINS = new Set([
buildConfig.get('urls').vanityUrl,
buildConfig.get('urls').checkoutUrl,
// TODO: Remove before merging PR - TESTING ONLY
'https://session-sync-example.vercel.app',
]);

async function fetchAndSetChannelSpecificUrlSettings() {
const {
data: {
site: { settings },
},
} = await client.fetch({
document: ChannelSpecificUrlSettingsQuery,
fetchOptions: { next: { revalidate } },
});

if (settings?.url) {
VALID_ORIGINS.add(settings.url.channelSpecificVanityUrl);
}

if (settings?.url.channelSpecificCheckoutUrl) {
VALID_ORIGINS.add(settings.url.channelSpecificCheckoutUrl);
}
}

export async function GET(request: NextRequest, { params }: { params: Promise<{ jwt: string }> }) {
const { jwt } = await params;
const referer = request.headers.get('referer');
const requestOrigin = referer ? new URL(referer).origin : null;
const query = request.nextUrl.searchParams;

if (!requestOrigin || !VALID_ORIGINS.has(requestOrigin)) {
return NextResponse.json(
{ message: 'Invalid origin' },
{ status: 403, statusText: 'Forbidden' },
);
}

const [, verifySessionSyncJwtResponse] = await Promise.all([
fetchAndSetChannelSpecificUrlSettings,
client.fetch({
document: VerifySessionSyncJwtMutation,
variables: { jwt },
fetchOptions: { cache: 'no-store' },
}),
]);

const { data, errors } = verifySessionSyncJwtResponse;

if (errors?.length) {
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500, statusText: 'Server error' },
);
}

const { content, errors: validationErrors } = data.validateSessionSyncJwt;

// Handle any validation errors or if the content is null
if (validationErrors.length || content === null) {
const error = validationErrors.at(0);

switch (error?.__typename) {
case 'InvalidSessionSyncJwtError':
return NextResponse.json(
{ message: 'Invalid Session Sync JWT' },
{ status: 400, statusText: 'Bad request' },
);

case 'JwtTokenExpiredError':
return NextResponse.redirect(request.referrer);

default:
return NextResponse.json(
{ message: 'Internal Server Error' },
{ status: 500, statusText: 'Server error' },
);
}
}

// Update the cart id if it exists regardless of if the customer is logged in or not
if (content.cart?.entityId) {
await setCartId(content.cart.entityId);
} else {
await clearCartId();
}

if (content.customer && content.customerAccessToken) {
await signIn('session-sync', {
name: `${content.customer.firstName} ${content.customer.lastName}`,
email: content.customer.email,
customerAccessToken: content.customerAccessToken.value,
redirect: false,
});
} else {
await signOut({ redirect: false });
}

if (query.get('redirect') === 'false') {
revalidatePath('/', 'layout');

return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': requestOrigin,
'Access-Control-Allow-Credentials': 'true',
},
});
}

const redirectTo = new URL(content.redirectTo);

if (!VALID_ORIGINS.has(redirectTo.origin)) {
return NextResponse.json(
{ message: 'Invalid redirectTo origin' },
{ status: 400, statusText: 'Bad request' },
);
}

revalidatePath('/', 'layout');

return NextResponse.redirect(redirectTo);
}

export const runtime = 'edge';
export const dynamic = 'force-dynamic';
29 changes: 29 additions & 0 deletions core/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ const SessionUpdate = z.object({
}),
});

const SessionSyncCredentials = z.object({
name: z.string(),
email: z.string().email(),
customerAccessToken: z.string(),
});

async function handleLoginCart(guestCartId?: string, loginResultCartId?: string) {
const t = await getTranslations('Cart');

Expand Down Expand Up @@ -168,6 +174,10 @@ function loginWithAnonymous(credentials: unknown): User | null {
};
}

function loginWithSessionSync(credentials: unknown): User {
return SessionSyncCredentials.parse(credentials);
}
Comment on lines +177 to +179
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little awkward... Usually you should handle the validation of the sign in here but because we are going to move in a direction where there will alway be a session, this CredentialsProvider will actually be removed and we will just update the session accordingly. We also need to handle things like the redirect and any API validation errors so it made more sense to do the validation in the route handler, rather than in the signIn function.


const config = {
// Explicitly setting this value to be undefined. We want the library to handle CSRF checks when taking sensitive actions.
// When handling sensitive actions like sign in, sign out, etc., the library will automatically check for CSRF tokens.
Expand All @@ -182,6 +192,18 @@ const config = {
pages: {
signIn: '/login',
},
cookies: {
callbackUrl: {
options: {
sameSite: 'none',
},
},
sessionToken: {
options: {
sameSite: 'none',
},
},
},
callbacks: {
jwt: ({ token, user, session, trigger }) => {
// user can actually be undefined
Expand Down Expand Up @@ -276,6 +298,13 @@ const config = {
},
authorize: loginWithJwt,
}),
CredentialsProvider({
id: 'session-sync',
credentials: {
jwt: { label: 'JWT', type: 'text' },
},
authorize: loginWithSessionSync,
}),
],
} satisfies NextAuthConfig;

Expand Down
29 changes: 29 additions & 0 deletions core/components/session-sync/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { useCallback, useEffect } from 'react';

export const SessionSyncProvider = () => {
const handleBrowserBackButtonEvent = useCallback((event: PageTransitionEvent) => {
if (event.persisted) {
try {
const previousOrigin = new URL(document.referrer).origin;

if (previousOrigin !== window.location.origin) {
window.location.reload();
}
} catch (error) {
console.error('Error parsing document.referrer:', error);
}
}
}, []);

useEffect(() => {
window.addEventListener('pageshow', handleBrowserBackButtonEvent);
Copy link

@davidchin davidchin Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the force reload happen on all pages when the back button is hit and bfcache is present, even if no session syncing is needed? Or does it happen only when the user clicks on the back button coming from BC checkout?

Copy link
Contributor

@willPrattUPL willPrattUPL Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we set a flag like "redirected_to_checkout" when users click the checkout button? We only reload conditionally

Then session sync provider deletes this cookie when it successfully reloads once.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to do some digging into here – I bet I can check the referrer before doing this.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤞 We can use the referrer. IIRC, BC doesn't have explicit referrer policy so it'd be the default (e.g.: strict-origin-when-cross-origin). We should at least be able to retrieve the origin value and disable bfcache if the request comes from a different origin. If we can't get this working reliably, I’d much prefer to do nothing and let users refresh the page themselves.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dug into this a bit and yeah strict-origin-when-cross-origin is mucking the waters here. And we probably can't add a cookie to this as we'll need some sort of validation that it was the other origin who set it - I could see a security issue where a bad actor could set the cookie and trigger infinite reloads, exhausting our resources in the process.

@bookernath from engineering perspective this might more effort than it's worth - just letting users reload the page and the session will update it. I think this is the best bet :(


return () => {
window.removeEventListener('pageshow', handleBrowserBackButtonEvent);
};
}, [handleBrowserBackButtonEvent]);

return null;
};
2 changes: 0 additions & 2 deletions core/middlewares/with-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ export const withAuth: MiddlewareFactory = (next) => {
const authWithCallback = auth(async (req) => {
if (!req.auth) {
await signIn('anonymous', { redirect: false });

return next(req, event);
}

// Continue the middleware chain
Expand Down
Loading