-
Notifications
You must be signed in to change notification settings - Fork 289
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
base: canary
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. |
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 | ||
} | ||
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'; |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤞 We can use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dug into this a bit and yeah @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; | ||
}; |
There was a problem hiding this comment.
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 thesignIn
function.