diff --git a/.env.example b/.env.example index 9ff0463db9..b3960fae2f 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,8 @@ SITE_NAME="Next.js Commerce" SHOPIFY_REVALIDATION_SECRET="" SHOPIFY_STOREFRONT_ACCESS_TOKEN="" SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" +# for customer account api +# SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID="" +# SHOPIFY_CUSTOMER_ACCOUNT_API_URL="" +# SHOPIFY_CUSTOMER_API_VERSION="" +# SHOPIFY_ORIGIN_URL="" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0298027e4f..1eee959c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ yarn-error.log* # vercel .vercel +.local +.upm +.replit +.replit.nix # typescript *.tsbuildinfo diff --git a/README.md b/README.md index 981685d2ba..08c5f6ff34 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,20 @@ Your app should now be running on [localhost:3000](http://localhost:3000/). ## Vercel, Next.js Commerce, and Shopify Integration Guide You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel. + +## Shopify Customer Accounts + +This fork is designed to provide a basic implementation of [Shopify's new Customer Accounts API](https://shopify.dev/docs/api/customer), which will allow a customer to login into their Next.js Shopify Website to update information and view orders, see Shopify's [launch announcement](https://www.shopify.com/partners/blog/introducing-customer-account-api-for-headless-stores) to learn more. + +It is based on Shopify's Hydrogen implementation and uses the concepts of Next.js middleware and server actions to implement the Shopify Customer Accounts API Integration. All the new code for the Customer Accounts API is included in: lib/shopify/customer folder, middleware.ts, and components/account + +The following files were changed in the core commerce repo: + +- components/cart/index.tsx (to add logged_in true for checkout for Customer Account) +- components/layout/navbar/index.tsx (to add a login button to menu) +- components/cart/modal.tsx (had to fix a TS error here) +- lib/utils.ts (add required ENV) +- README +- env.example + +For instructions on how to get everything working properly, please see [Setup for using Shopify Customer Account API](https://www.dalicommerce.com/docs/nextjs/create-a-headless-shopify-nextjs#iii-setup-for-using-shopify-customer-account-api-log-in-and-account-section) diff --git a/app/(auth)/authorize/page.tsx b/app/(auth)/authorize/page.tsx new file mode 100644 index 0000000000..723f8befb3 --- /dev/null +++ b/app/(auth)/authorize/page.tsx @@ -0,0 +1,25 @@ +import { headers } from 'next/headers'; +export const runtime = 'edge'; +export default async function AuthorizationPage() { + const headersList = headers(); + const access = headersList.get('x-shop-access'); + if (!access) { + console.log('ERROR: No access header'); + throw new Error('No access header'); + } + console.log('Authorize Access code header:', access); + if (access === 'denied') { + console.log('Access Denied for Auth'); + throw new Error('No access allowed'); + } + + return ( + <> +
+
+
Loading...
+
+
+ + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000000..dce7c33dd4 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,16 @@ +import { LoginMessage } from 'components/auth/login-message'; +export const runtime = 'edge'; //this needs to be here on thie page. I don't know why + +export default async function LoginPage() { + return ( + <> +
+
+
+ +
+
+
+ + ); +} diff --git a/app/(auth)/logout/page.tsx b/app/(auth)/logout/page.tsx new file mode 100644 index 0000000000..3b7080c251 --- /dev/null +++ b/app/(auth)/logout/page.tsx @@ -0,0 +1,13 @@ +export const runtime = 'edge'; + +export default async function LogoutPage() { + return ( + <> +
+
+
Loading...
+
+
+ + ); +} diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000000..62eb7de5f3 --- /dev/null +++ b/app/account/page.tsx @@ -0,0 +1,85 @@ +import { headers } from 'next/headers'; +import { AccountProfile } from 'components/account/account-profile'; +import { AccountOrdersHistory } from 'components/account/account-orders-history'; +import { redirect } from 'next/navigation'; +import { shopifyCustomerFetch } from 'lib/shopify/customer/index'; +import { CUSTOMER_DETAILS_QUERY } from 'lib/shopify/customer/queries/customer'; +import { CustomerDetailsData } from 'lib/shopify/customer/types'; +import { TAGS } from 'lib/shopify/customer/constants'; +export const runtime = 'edge'; +export default async function AccountPage() { + const headersList = headers(); + const access = headersList.get('x-shop-customer-token'); + if (!access) { + console.log('ERROR: No access header account'); + //I'm not sure what's better here. Throw error or just log out?? + //redirect gets rid of call cookies + redirect('/logout'); + //throw new Error("No access header") + } + //console.log("Authorize Access code header:", access) + if (access === 'denied') { + console.log('Access Denied for Auth account'); + redirect('/logout'); + //throw new Error("No access allowed") + } + const customerAccessToken = access; + + //this is needed b/c of strange way server components handle redirects etc. + //see https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting + //can only redirect outside of try/catch! + let success = true; + let errorMessage; + let customerData; + let orders; + + try { + const responseCustomerDetails = await shopifyCustomerFetch({ + customerToken: customerAccessToken, + cache: 'no-store', + query: CUSTOMER_DETAILS_QUERY, + tags: [TAGS.customer] + }); + //console.log("userDetails", responseCustomerDetails) + const userDetails = responseCustomerDetails.body; + if (!userDetails) { + throw new Error('Error getting actual user data Account page.'); + } + customerData = userDetails?.data?.customer; + orders = customerData?.orders?.edges; + //console.log ("Details",orders) + } catch (e) { + //they don't recognize this error in TS! + //@ts-ignore + errorMessage = e?.error?.toString() ?? 'Unknown Error'; + console.log('error customer fetch account', e); + if (errorMessage !== 'unauthorized') { + throw new Error('Error getting actual user data Account page.'); + } else { + console.log('Unauthorized access. Set to false and redirect'); + success = false; + } + } + if (!success && errorMessage === 'unauthorized') redirect('/logout'); + //revalidateTag('posts') // Update cached posts //FIX + + return ( + <> +
+
+
+
Welcome: {customerData?.emailAddress.emailAddress}
+
+
+
+ +
+
+
+
{orders && }
+
+
+
+ + ); +} diff --git a/components/account/account-orders-history.tsx b/components/account/account-orders-history.tsx new file mode 100644 index 0000000000..6ad8046c3d --- /dev/null +++ b/components/account/account-orders-history.tsx @@ -0,0 +1,41 @@ +'use client'; +type OrderCardsProps = { + orders: any; +}; + +export function AccountOrdersHistory({ orders }: { orders: any }) { + return ( +
+
+

Order History

+ {orders?.length ? : } +
+
+ ); +} + +function EmptyOrders() { + return ( +
+
You haven't placed any orders yet.
+
+ +
+
+ ); +} + +function Orders({ orders }: OrderCardsProps) { + return ( + + ); +} diff --git a/components/account/account-profile.tsx b/components/account/account-profile.tsx new file mode 100644 index 0000000000..92bc2d867f --- /dev/null +++ b/components/account/account-profile.tsx @@ -0,0 +1,46 @@ +'use client'; +import clsx from 'clsx'; +import { ArrowRightIcon as LogOutIcon } from '@heroicons/react/24/outline'; +import { doLogout } from './actions'; +import LoadingDots from 'components/loading-dots'; +import { useFormState, useFormStatus } from 'react-dom'; + +function SubmitButton(props: any) { + const { pending } = useFormStatus(); + const buttonClasses = + 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; + return ( + <> + + {props?.message &&
{props?.message}
} + + ); +} + +export function AccountProfile() { + const [message, formAction] = useFormState(doLogout, null); + + return ( +
+ +

+ {message} +

+ + ); +} diff --git a/components/account/actions.ts b/components/account/actions.ts new file mode 100644 index 0000000000..1c4d526796 --- /dev/null +++ b/components/account/actions.ts @@ -0,0 +1,40 @@ +'use server'; + +import { TAGS } from 'lib/shopify/customer/constants'; +import { removeAllCookiesServerAction } from 'lib/shopify/customer/auth-helpers'; +import { redirect } from 'next/navigation'; +import { revalidateTag } from 'next/cache'; +import { cookies } from 'next/headers'; +import { SHOPIFY_ORIGIN, SHOPIFY_CUSTOMER_ACCOUNT_API_URL } from 'lib/shopify/customer/constants'; +//import {generateCodeVerifier,generateCodeChallenge,generateRandomString} from 'lib/shopify/customer/auth-utils' + +export async function doLogout(prevState: any) { + //let logoutUrl = '/logout' + const origin = SHOPIFY_ORIGIN; + const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; + let logoutUrl; + try { + const idToken = cookies().get('shop_id_token'); + const idTokenValue = idToken?.value; + if (!idTokenValue) { + //you can also throw an error here with page and middleware + //throw new Error ("Error No Id Token") + //if there is no idToken, then sending to logout url will redirect shopify, so just + //redirect to login here and delete cookies (presumably they don't even exist) + logoutUrl = new URL(`${origin}/login`); + } else { + logoutUrl = new URL( + `${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}` + ); + } + await removeAllCookiesServerAction(); + revalidateTag(TAGS.customer); + } catch (e) { + console.log('Error', e); + //you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary + //throw new Error ("Error") + return 'Error logging out. Please try again'; + } + + redirect(`${logoutUrl}`); // Navigate to the new post page +} diff --git a/components/auth/actions.ts b/components/auth/actions.ts new file mode 100644 index 0000000000..36517cfd98 --- /dev/null +++ b/components/auth/actions.ts @@ -0,0 +1,69 @@ +//See https://react.dev/reference/react-dom/hooks/useFormState +//https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#forms +'use server'; + +import { TAGS } from 'lib/shopify/customer/constants'; +import { redirect } from 'next/navigation'; +import { revalidateTag } from 'next/cache'; +import { cookies } from 'next/headers'; +//import { getOrigin } from 'lib/shopify/customer' +import { + generateCodeVerifier, + generateCodeChallenge, + generateRandomString +} from 'lib/shopify/customer/auth-utils'; +import { + SHOPIFY_CUSTOMER_ACCOUNT_API_URL, + SHOPIFY_CLIENT_ID, + SHOPIFY_ORIGIN +} from 'lib/shopify/customer/constants'; + +export async function doLogin(prevState: any) { + const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; + const clientId = SHOPIFY_CLIENT_ID; + const origin = SHOPIFY_ORIGIN; + const loginUrl = new URL(`${customerAccountApiUrl}/auth/oauth/authorize`); + //console.log ("previous", prevState) + + try { + //await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]); + loginUrl.searchParams.set('client_id', clientId); + loginUrl.searchParams.append('response_type', 'code'); + loginUrl.searchParams.append('redirect_uri', `${origin}/authorize`); + loginUrl.searchParams.set( + 'scope', + 'openid email https://api.customers.com/auth/customer.graphql' + ); + const verifier = await generateCodeVerifier(); + //const newVerifier = verifier.replace("+", '_').replace("-",'_').replace("/",'_').trim() + const challenge = await generateCodeChallenge(verifier); + cookies().set('shop_verifier', verifier as string, { + // @ts-ignore + //expires: auth?.expires, //not necessary here + }); + const state = await generateRandomString(); + const nonce = await generateRandomString(); + cookies().set('shop_state', state as string, { + // @ts-ignore + //expires: auth?.expires, //not necessary here + }); + cookies().set('shop_nonce', nonce as string, { + // @ts-ignore + //expires: auth?.expires, //not necessary here + }); + loginUrl.searchParams.append('state', state); + loginUrl.searchParams.append('nonce', nonce); + loginUrl.searchParams.append('code_challenge', challenge); + loginUrl.searchParams.append('code_challenge_method', 'S256'); + //console.log ("loginURL", loginUrl) + //throw new Error ("Error") //this is how you throw an error, if you want to. Then the catch will execute + } catch (e) { + console.log('Error', e); + //you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary + //throw new Error ("Error") + return 'Error logging in. Please try again'; + } + + revalidateTag(TAGS.customer); + redirect(`${loginUrl}`); // Navigate to the new post page +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000000..3410b0e84b --- /dev/null +++ b/components/auth/login-form.tsx @@ -0,0 +1,51 @@ +'use client'; +import clsx from 'clsx'; +import { doLogin } from './actions'; +import { useFormState, useFormStatus } from 'react-dom'; + +function SubmitButton(props: any) { + const { pending } = useFormStatus(); + const buttonClasses = + 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; + //const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60'; + + return ( + <> + {props?.message &&
{props?.message}
} + + + ); +} + +export function LoginShopify() { + const [message, formAction] = useFormState(doLogin, null); + + return ( +
+ +

+ {message} +

+ + ); +} diff --git a/components/auth/login-message.tsx b/components/auth/login-message.tsx new file mode 100644 index 0000000000..f32d8bd1c6 --- /dev/null +++ b/components/auth/login-message.tsx @@ -0,0 +1,8 @@ +export function LoginMessage() { + return ( +
+

Error

+ Your session has expired. Please log in again. +
+ ); +} diff --git a/components/auth/login.tsx b/components/auth/login.tsx new file mode 100644 index 0000000000..8346013e12 --- /dev/null +++ b/components/auth/login.tsx @@ -0,0 +1,19 @@ +import { cookies } from 'next/headers'; +import { LoginShopify } from 'components/auth/login-form'; +import { UserIcon } from 'components/auth/user-icon'; + +export default async function Login() { + const customerToken = cookies().get('shop_customer_token')?.value; + const refreshToken = cookies().get('shop_refresh_token')?.value; + let isLoggedIn; + //obviously just checking for the cookies without verifying the cookie itself is not ideal. However, the cookie is validated on the + //account page, so a "fake" cookie does nothing, except show the UI and then it would be deleted when clicking on account + //so for now, just checking the cookie for the UI is sufficient. Alternatively, we can do a query here, or a custom JWT + if (!customerToken && !refreshToken) { + isLoggedIn = false; + } else { + isLoggedIn = true; + } + console.log('LoggedIn', isLoggedIn); + return isLoggedIn ? : ; +} diff --git a/components/auth/user-icon.tsx b/components/auth/user-icon.tsx new file mode 100644 index 0000000000..fa39fdece4 --- /dev/null +++ b/components/auth/user-icon.tsx @@ -0,0 +1,30 @@ +'use client'; +import { UserIcon as User2Icon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; + +function UserButton(props: any) { + const buttonClasses = + 'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white'; + //const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60'; + + return ( + <> + + + ); +} + +export function UserIcon() { + return ; +} diff --git a/components/cart/index.tsx b/components/cart/index.tsx index 3e250ba93c..55e2c5123f 100644 --- a/components/cart/index.tsx +++ b/components/cart/index.tsx @@ -1,14 +1,24 @@ import { getCart } from 'lib/shopify'; import { cookies } from 'next/headers'; import CartModal from './modal'; +import type { Cart } from 'lib/shopify/types'; export default async function Cart() { const cartId = cookies().get('cartId')?.value; let cart; if (cartId) { - cart = await getCart(cartId); + cart = (await getCart(cartId)) as Cart; + //pass logged_in true to shopify checout to utilize customer api + //see: https://shopify.dev/docs/api/customer#step-stay-authenticated-on-checkout + const newCheckoutUrl = new URL(cart?.checkoutUrl || ''); + newCheckoutUrl.searchParams.append('logged_in', 'true'); + cart = { + ...cart, + checkoutUrl: newCheckoutUrl.toString() + }; + return ; + } else { + return ; } - - return ; } diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index aee2f7a47a..cecabcf601 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -18,7 +18,7 @@ type MerchandiseSearchParams = { [key: string]: string; }; -export default function CartModal({ cart }: { cart: Cart | undefined }) { +export default function CartModal({ cart }: { cart?: Cart }) { const [isOpen, setIsOpen] = useState(false); const quantityRef = useRef(cart?.totalQuantity); const openCart = () => setIsOpen(true); diff --git a/components/layout/navbar/index.tsx b/components/layout/navbar/index.tsx index 0058d5ec8d..9c240fa062 100644 --- a/components/layout/navbar/index.tsx +++ b/components/layout/navbar/index.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import { Suspense } from 'react'; import MobileMenu from './mobile-menu'; import Search from './search'; +import Login from 'components/auth/login'; const { SITE_NAME } = process.env; export default async function Navbar() { @@ -44,9 +45,26 @@ export default async function Navbar() {
- }> - - +
+
+
+ }> + + +
+
+ Login

}> + +
+
+
+
diff --git a/lib/shopify/customer/auth-helpers.ts b/lib/shopify/customer/auth-helpers.ts new file mode 100644 index 0000000000..314d5548c5 --- /dev/null +++ b/lib/shopify/customer/auth-helpers.ts @@ -0,0 +1,286 @@ +//you need to remain this as type so as not to confuse with the actual function +import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; +import { cookies } from 'next/headers'; +import { getNonce } from 'lib/shopify/customer/auth-utils'; +import { + SHOPIFY_CUSTOMER_ACCOUNT_API_URL, + SHOPIFY_USER_AGENT, + SHOPIFY_CLIENT_ID +} from './constants'; + +export async function initialAccessToken( + request: NextRequest, + newOrigin: string, + customerAccountApiUrl: string, + clientId: string +) { + const code = request.nextUrl.searchParams.get('code'); + const state = request.nextUrl.searchParams.get('state'); + /* + STEP 1: Check for all necessary cookies and other information + */ + if (!code) { + console.log('Error: No Code Auth'); + return { success: false, message: `No Code` }; + } + if (!state) { + console.log('Error: No State Auth'); + return { success: false, message: `No State` }; + } + const shopState = request.cookies.get('shop_state'); + const shopStateValue = shopState?.value; + if (!shopStateValue) { + console.log('Error: No Shop State Value'); + return { success: false, message: `No Shop State` }; + } + if (state !== shopStateValue) { + console.log('Error: Shop state mismatch'); + return { success: false, message: `No Shop State Mismatch` }; + } + const codeVerifier = request.cookies.get('shop_verifier'); + const codeVerifierValue = codeVerifier?.value; + if (!codeVerifierValue) { + console.log('No Code Verifier'); + return { success: false, message: `No Code Verifier` }; + } + /* + STEP 2: GET ACCESS TOKEN + */ + const body = new URLSearchParams(); + body.append('grant_type', 'authorization_code'); + body.append('client_id', clientId); + body.append('redirect_uri', `${newOrigin}/authorize`); + body.append('code', code); + body.append('code_verifier', codeVerifier?.value); + const userAgent = '*'; + const headersNew = new Headers(); + headersNew.append('Content-Type', 'application/x-www-form-urlencoded'); + headersNew.append('User-Agent', userAgent); + headersNew.append('Origin', newOrigin || ''); + const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`; + const response = await fetch(tokenRequestUrl, { + method: 'POST', + headers: headersNew, + body + }); + const data = await response.json(); + console.log('data initial access token', data); + if (!response.ok) { + console.log('data response error auth', data.error); + console.log('response auth', response.status); + return { success: false, message: `Response error auth` }; + } + if (data?.errors) { + const errorMessage = data?.errors?.[0]?.message ?? 'Unknown error auth'; + return { success: false, message: `${errorMessage}` }; + } + const nonce = await getNonce(data?.id_token || ''); + const shopNonce = request.cookies.get('shop_nonce'); + const shopNonceValue = shopNonce?.value; + console.log('sent nonce', nonce); + console.log('original nonce', shopNonceValue); + if (nonce !== shopNonceValue) { + //make equal === to force error for testing + console.log('Error nonce match'); + return { success: false, message: `Error: Nonce mismatch` }; + } + return { success: true, data }; +} + +export async function exchangeAccessToken( + token: string, + customerAccountId: string, + customerAccountApiUrl: string, + origin: string +) { + const clientId = customerAccountId; + //this is a constant - see the docs. https://shopify.dev/docs/api/customer#useaccesstoken-propertydetail-audience + const customerApiClientId = '30243aa5-17c1-465a-8493-944bcc4e88aa'; + const accessToken = token; + const body = new URLSearchParams(); + body.append('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange'); + body.append('client_id', clientId); + body.append('audience', customerApiClientId); + body.append('subject_token', accessToken); + body.append('subject_token_type', 'urn:ietf:params:oauth:token-type:access_token'); + body.append('scopes', 'https://api.customers.com/auth/customer.graphql'); + + const userAgent = '*'; + + const headers = new Headers(); + headers.append('Content-Type', 'application/x-www-form-urlencoded'); + headers.append('User-Agent', userAgent); + headers.append('Origin', origin); + + // Token Endpoint goes here + const response = await fetch(`${customerAccountApiUrl}/auth/oauth/token`, { + method: 'POST', + headers, + body + }); + + const data = await response.json(); + if (data.error) { + return { success: false, data: data?.error_description }; + } + return { success: true, data }; +} + +export async function refreshToken({ request, origin }: { request: NextRequest; origin: string }) { + const newBody = new URLSearchParams(); + const refreshToken = request.cookies.get('shop_refresh_token'); + const refreshTokenValue = refreshToken?.value; + if (!refreshTokenValue) { + console.log('Error: No Refresh Token'); + return { success: false, message: `no_refresh_token` }; + } + const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; + const clientId = SHOPIFY_CLIENT_ID; + const userAgent = SHOPIFY_USER_AGENT; + newBody.append('grant_type', 'refresh_token'); + newBody.append('refresh_token', refreshTokenValue); + newBody.append('client_id', clientId); + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': userAgent, + Origin: origin + }; + const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`; + const response = await fetch(tokenRequestUrl, { + method: 'POST', + headers, + body: newBody + }); + + if (!response.ok) { + const text = await response.text(); + console.log('response error in refresh token', text); + return { success: false, message: `no_refresh_token` }; + } + const data = await response.json(); + console.log('data response from initial fetch to refresh', data); + const { access_token, expires_in, refresh_token } = data; + + const customerAccessToken = await exchangeAccessToken( + access_token, + clientId, + customerAccountApiUrl, + origin + ); + // console.log("Customer Access Token in refresh request", customerAccessToken) + if (!customerAccessToken.success) { + return { success: false, message: `no_refresh_token` }; + } + + //const expiresAt = new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime() + '' + //const idToken = id_token + + return { + success: true, + data: { customerAccessToken: customerAccessToken.data.access_token, expires_in, refresh_token } + }; +} + +export async function checkExpires({ + request, + expiresAt, + origin +}: { + request: NextRequest; + expiresAt: string; + origin: string; +}) { + let isExpired = false; + if (parseInt(expiresAt, 10) - 1000 < new Date().getTime()) { + isExpired = true; + console.log('Isexpired is true, we are running refresh token!'); + const refresh = await refreshToken({ request, origin }); + console.log('refresh', refresh); + //this will return success: true or success: false - depending on result of refresh + return { ranRefresh: isExpired, refresh }; + } + console.log('is expired is false - just sending back success', isExpired); + return { ranRefresh: isExpired, success: true }; +} + +export function removeAllCookies(response: NextResponseType) { + //response.cookies.delete('shop_auth_token') //never set. We don't use it anywhere. + response.cookies.delete('shop_customer_token'); + response.cookies.delete('shop_refresh_token'); + response.cookies.delete('shop_id_token'); + response.cookies.delete('shop_state'); + response.cookies.delete('shop_nonce'); + response.cookies.delete('shop_verifier'); + response.cookies.delete('shop_expires_at'); + return response; +} + +export async function removeAllCookiesServerAction() { + cookies().delete('shop_customer_token'); + cookies().delete('shop_refresh_token'); + cookies().delete('shop_id_token'); + cookies().delete('shop_state'); + cookies().delete('shop_nonce'); + cookies().delete('shop_verifier'); + cookies().delete('shop_expires_at'); + return { success: true }; +} + +export async function createAllCookies({ + response, + customerAccessToken, + expires_in, + refresh_token, + expiresAt, + id_token +}: { + response: NextResponseType; + customerAccessToken: string; + expires_in: number; + refresh_token: string; + expiresAt: string; + id_token?: string; +}) { + response.cookies.set('shop_customer_token', customerAccessToken, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', + secure: true, + path: '/', + maxAge: expires_in //value from shopify, seems like this is 2 hours + }); + + //you need to set an expiration here, because otherwise its a sessions cookie + //and will disappear after the user closes the browser and then we can never refresh - same with expires at below + response.cookies.set('shop_refresh_token', refresh_token, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', + secure: true, + path: '/', + maxAge: 604800 //one week + }); + + //you need to set an expiration here, because otherwise its a sessions cookie + //and will disappear after the user closes the browser and then we can never refresh + response.cookies.set('shop_expires_at', expiresAt, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', + secure: true, + path: '/', + maxAge: 604800 //one week + }); + + //required for logout - this must be the same as the original expires - it;s long lived so they can logout, otherwise it will expire + //because that's how we got the token, if this is different, it won't work + //we don't always send in id_token here. For example, on refresh it's not available, it's only sent in on the initial authorization + if (id_token) { + response.cookies.set('shop_id_token', id_token, { + httpOnly: true, //if true can only read the cookie in server + sameSite: 'lax', //should be lax??? + secure: true, + path: '/', + maxAge: 604800 //one week + }); + } + + return response; +} diff --git a/lib/shopify/customer/auth-utils.ts b/lib/shopify/customer/auth-utils.ts new file mode 100644 index 0000000000..63baa8a07f --- /dev/null +++ b/lib/shopify/customer/auth-utils.ts @@ -0,0 +1,48 @@ +// @ts-nocheck +export async function generateCodeVerifier() { + const randomCode = generateRandomCode(); + return base64UrlEncode(randomCode); +} +export async function generateCodeChallenge(codeVerifier: string) { + const digestOp = await crypto.subtle.digest( + { name: 'SHA-256' }, + new TextEncoder().encode(codeVerifier) + ); + const hash = convertBufferToString(digestOp); + return base64UrlEncode(hash); +} +function generateRandomCode() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return String.fromCharCode.apply(null, Array.from(array)); +} +function base64UrlEncode(str: string) { + const base64 = btoa(str); + // This is to ensure that the encoding does not have +, /, or = characters in it. + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} +function convertBufferToString(hash: ArrayBuffer) { + const uintArray = new Uint8Array(hash); + const numberArray = Array.from(uintArray); + return String.fromCharCode(...numberArray); +} + +export async function generateRandomString() { + const timestamp = Date.now().toString(); + const randomString = Math.random().toString(36).substring(2); + return timestamp + randomString; +} + +export async function getNonce(token: string) { + return decodeJwt(token).payload.nonce; +} +function decodeJwt(token: string) { + const [header, payload, signature] = token.split('.'); + const decodedHeader = JSON.parse(atob(header || '')); + const decodedPayload = JSON.parse(atob(payload || '')); + return { + header: decodedHeader, + payload: decodedPayload, + signature + }; +} diff --git a/lib/shopify/customer/constants.ts b/lib/shopify/customer/constants.ts new file mode 100644 index 0000000000..3c04d91624 --- /dev/null +++ b/lib/shopify/customer/constants.ts @@ -0,0 +1,10 @@ +export const TAGS = { + customer: 'customer' +}; + +//ENVs +export const SHOPIFY_CUSTOMER_ACCOUNT_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL || ''; +export const SHOPIFY_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || ''; +export const SHOPIFY_CUSTOMER_API_VERSION = process.env.SHOPIFY_CUSTOMER_API_VERSION || ''; +export const SHOPIFY_USER_AGENT = '*'; +export const SHOPIFY_ORIGIN = process.env.SHOPIFY_ORIGIN_URL || ''; diff --git a/lib/shopify/customer/index.ts b/lib/shopify/customer/index.ts new file mode 100644 index 0000000000..57873472c8 --- /dev/null +++ b/lib/shopify/customer/index.ts @@ -0,0 +1,287 @@ +import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; +import { NextResponse } from 'next/server'; +//import { revalidateTag } from 'next/cache'; +import { + checkExpires, + removeAllCookies, + initialAccessToken, + exchangeAccessToken, + createAllCookies +} from './auth-helpers'; +import { isShopifyError } from 'lib/type-guards'; +import { parseJSON } from 'lib/shopify/customer/utils/parse-json'; +import { + SHOPIFY_CUSTOMER_ACCOUNT_API_URL, + SHOPIFY_USER_AGENT, + SHOPIFY_CUSTOMER_API_VERSION, + SHOPIFY_CLIENT_ID, + SHOPIFY_ORIGIN +} from './constants'; + +type ExtractVariables = T extends { variables: object } ? T['variables'] : never; +const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL; +const apiVersion = SHOPIFY_CUSTOMER_API_VERSION; +const userAgent = SHOPIFY_USER_AGENT; +const customerEndpoint = `${customerAccountApiUrl}/account/customer/api/${apiVersion}/graphql`; + +//NEVER CACHE THIS! Doesn't see to be cached anyway b/c +//https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching +//The fetch request comes after the usage of headers or cookies. +//and we always send this anyway after getting a cookie for the customer +export async function shopifyCustomerFetch({ + cache = 'no-store', + customerToken, + query, + tags, + variables +}: { + cache?: RequestCache; + customerToken: string; + query: string; + tags?: string[]; + variables?: ExtractVariables; +}): Promise<{ status: number; body: T } | never> { + try { + const customerOrigin = SHOPIFY_ORIGIN; + const result = await fetch(customerEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + Origin: customerOrigin, + Authorization: customerToken + }, + body: JSON.stringify({ + ...(query && { query }), + ...(variables && { variables }) + }), + cache: 'no-store', //NEVER CACHE THE CUSTOMER REQUEST!!! + ...(tags && { next: { tags } }) + }); + + const body = await result.json(); + + if (!result.ok) { + //the statuses here could be different, a 401 means + //https://shopify.dev/docs/api/customer#endpoints + //401 means the token is bad + console.log('Error in Customer Fetch Status', body.errors); + if (result.status === 401) { + // clear session because current access token is invalid + const errorMessage = 'unauthorized'; + throw errorMessage; //this should throw in the catch below in the non-shopify catch + } + let errors; + try { + errors = parseJSON(body); + } catch (_e) { + errors = [{ message: body }]; + } + throw errors; + } + + //this just throws an error and the error boundary is called + if (body.errors) { + //throw 'Error' + console.log('Error in Customer Fetch', body.errors[0]); + throw body.errors[0]; + } + + return { + status: result.status, + body + }; + } catch (e) { + if (isShopifyError(e)) { + throw { + cause: e.cause?.toString() || 'unknown', + status: e.status || 500, + message: e.message, + query + }; + } + + throw { + error: e, + query + }; + } +} + +export async function isLoggedIn(request: NextRequest, origin: string) { + const customerToken = request.cookies.get('shop_customer_token'); + const customerTokenValue = customerToken?.value; + const refreshToken = request.cookies.get('shop_refresh_token'); + const refreshTokenValue = refreshToken?.value; + const newHeaders = new Headers(request.headers); + if (!customerTokenValue && !refreshTokenValue) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + } + + const expiresToken = request.cookies.get('shop_expires_at'); + const expiresTokenValue = expiresToken?.value; + if (!expiresTokenValue) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + //return { success: false, message: `no_expires_at` } + } + const isExpired = await checkExpires({ + request: request, + expiresAt: expiresTokenValue, + origin: origin + }); + console.log('is Expired?', isExpired); + //only execute the code below to reset the cookies if it was expired! + if (isExpired.ranRefresh) { + const isSuccess = isExpired?.refresh?.success; + if (!isSuccess) { + const redirectUrl = new URL(`${origin}`); + const response = NextResponse.redirect(`${redirectUrl}`); + return removeAllCookies(response); + //return { success: false, message: `no_refresh_token` } + } else { + const refreshData = isExpired?.refresh?.data; + //console.log ("refresh data", refreshData) + console.log('We used the refresh token, so now going to reset the token and cookies'); + const newCustomerAccessToken = refreshData?.customerAccessToken; + const expires_in = refreshData?.expires_in; + //const test_expires_in = 180 //to test to see if it expires in 60 seconds! + const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; + newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`); + const resetCookieResponse = NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + return await createAllCookies({ + response: resetCookieResponse, + customerAccessToken: newCustomerAccessToken, + expires_in, + refresh_token: refreshData?.refresh_token, + expiresAt + }); + } + } + + newHeaders.set('x-shop-customer-token', `${customerTokenValue}`); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); +} + +//when we are running on the production website we just get the origin from the request.nextUrl +export function getOrigin(request: NextRequest) { + const nextOrigin = request.nextUrl.origin; + //console.log("Current Origin", nextOrigin) + //when running localhost, we want to use fake origin otherwise we use the real origin + let newOrigin = nextOrigin; + if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') { + newOrigin = SHOPIFY_ORIGIN; + } else { + newOrigin = nextOrigin; + } + return newOrigin; +} + +export async function authorizeFn(request: NextRequest, origin: string) { + const clientId = SHOPIFY_CLIENT_ID; + const newHeaders = new Headers(request.headers); + /*** + STEP 1: Get the initial access token or deny access + ****/ + const dataInitialToken = await initialAccessToken( + request, + origin, + customerAccountApiUrl, + clientId + ); + if (!dataInitialToken.success) { + console.log('Error: Access Denied. Check logs', dataInitialToken.message); + newHeaders.set('x-shop-access', 'denied'); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + } + const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data; + /*** + STEP 2: Get a Customer Access Token + ****/ + const customerAccessToken = await exchangeAccessToken( + access_token, + clientId, + customerAccountApiUrl, + origin || '' + ); + if (!customerAccessToken.success) { + console.log('Error: Customer Access Token'); + newHeaders.set('x-shop-access', 'denied'); + return NextResponse.next({ + request: { + // New request headers + headers: newHeaders + } + }); + } + //console.log("customer access Token", customerAccessToken.data.access_token) + /**STEP 3: Set Customer Access Token cookies + We are setting the cookies here b/c if we set it on the request, and then redirect + it doesn't see to set sometimes + **/ + newHeaders.set('x-shop-access', 'allowed'); + /* + const authResponse = NextResponse.next({ + request: { + // New request headers + headers: newHeaders, + }, + }) + */ + const accountUrl = new URL(`${origin}/account`); + const authResponse = NextResponse.redirect(`${accountUrl}`); + + //sets an expires time 2 minutes before expiration which we can use in refresh strategy + //const test_expires_in = 180 //to test to see if it expires in 60 seconds! + const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + ''; + + return await createAllCookies({ + response: authResponse, + customerAccessToken: customerAccessToken?.data?.access_token, + expires_in, + refresh_token, + expiresAt, + id_token + }); +} + +export async function logoutFn(request: NextRequest, origin: string) { + //console.log("New Origin", newOrigin) + const idToken = request.cookies.get('shop_id_token'); + const idTokenValue = idToken?.value; + //revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now + + //if there is no idToken, then sending to logout url will redirect shopify, so just + //redirect to login here and delete cookies (presumably they don't even exist) + if (!idTokenValue) { + const logoutUrl = new URL(`${origin}/login`); + const response = NextResponse.redirect(`${logoutUrl}`); + return removeAllCookies(response); + } + + //console.log ("id toke value", idTokenValue) + const logoutUrl = new URL( + `${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}` + ); + //console.log ("logout url", logoutUrl) + const logoutResponse = NextResponse.redirect(logoutUrl); + return removeAllCookies(logoutResponse); +} diff --git a/lib/shopify/customer/queries/customer.ts b/lib/shopify/customer/queries/customer.ts new file mode 100644 index 0000000000..a121101158 --- /dev/null +++ b/lib/shopify/customer/queries/customer.ts @@ -0,0 +1,97 @@ +//https://shopify.dev/docs/api/customer/2024-01/queries/customer +export const CUSTOMER_ME_QUERY = /* GraphQL */ ` + query customer { + customer { + emailAddress { + emailAddress + } + firstName + lastName + tags + } + } +`; + +const CUSTOMER_FRAGMENT = `#graphql + fragment OrderCard on Order { + id + number + processedAt + financialStatus + fulfillments(first: 1) { + nodes { + status + } + } + totalPrice { + amount + currencyCode + } + lineItems(first: 2) { + edges { + node { + title + image { + altText + height + url + width + } + } + } + } + } + + fragment AddressPartial on CustomerAddress { + id + formatted + firstName + lastName + company + address1 + address2 + territoryCode + zoneCode + city + zip + phoneNumber + } + + fragment CustomerDetails on Customer { + firstName + lastName + phoneNumber { + phoneNumber + } + emailAddress { + emailAddress + } + defaultAddress { + ...AddressPartial + } + addresses(first: 6) { + edges { + node { + ...AddressPartial + } + } + } + orders(first: 250, sortKey: PROCESSED_AT, reverse: true) { + edges { + node { + ...OrderCard + } + } + } + } +` as const; + +// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer +export const CUSTOMER_DETAILS_QUERY = `#graphql + query CustomerDetails { + customer { + ...CustomerDetails + } + } + ${CUSTOMER_FRAGMENT} +` as const; diff --git a/lib/shopify/customer/types.ts b/lib/shopify/customer/types.ts new file mode 100644 index 0000000000..2f9915148d --- /dev/null +++ b/lib/shopify/customer/types.ts @@ -0,0 +1,36 @@ +export type Maybe = T | null; + +export type Connection = { + edges: Array>; +}; + +export type Edge = { + node: T; +}; + +export type CustomerData = { + data: { + customer: { + emailAddress: { + emailAddress: string; + }; + firstName: string; + lastName: string; + tags: any[]; + }; + }; +}; + +export type GenericObject = { [key: string]: any }; + +export type CustomerDetailsData = { + data: { + customer: { + emailAddress: { + emailAddress: string; + }; + // Using GenericObject to type 'orders' since the fields are not known in advance + orders: Connection; + }; + }; +}; diff --git a/lib/shopify/customer/utils/parse-json.ts b/lib/shopify/customer/utils/parse-json.ts new file mode 100644 index 0000000000..3bf536d16d --- /dev/null +++ b/lib/shopify/customer/utils/parse-json.ts @@ -0,0 +1,7 @@ +export function parseJSON(json: any) { + if (String(json).includes('__proto__')) return JSON.parse(json, noproto); + return JSON.parse(json); +} +function noproto(k: string, v: string) { + if (k !== '__proto__') return v; +} diff --git a/lib/utils.ts b/lib/utils.ts index 69b76d29b9..89df928008 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -11,7 +11,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) => stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`; export const validateEnvironmentVariables = () => { - const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN']; + const requiredEnvironmentVariables = [ + 'SHOPIFY_STORE_DOMAIN', + 'SHOPIFY_STOREFRONT_ACCESS_TOKEN', + 'SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID', + 'SHOPIFY_CUSTOMER_ACCOUNT_API_URL', + 'SHOPIFY_CUSTOMER_API_VERSION', + 'SHOPIFY_ORIGIN_URL' + ]; const missingEnvironmentVariables = [] as string[]; requiredEnvironmentVariables.forEach((envVar) => { diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000000..9d831e7f3b --- /dev/null +++ b/middleware.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from 'next/server'; +import { isLoggedIn, getOrigin, authorizeFn, logoutFn } from 'lib/shopify/customer'; + +// This function can be marked `async` if using `await` inside +export async function middleware(request: NextRequest) { + /**** + Authorize Middleware to get access tokens + *****/ + if (request.nextUrl.pathname.startsWith('/authorize')) { + console.log('Running Initial Authorization Middleware'); + const origin = getOrigin(request); + //console.log ("origin", origin) + return await authorizeFn(request, origin); + } + /**** + END OF Authorize Middleware to get access tokens + *****/ + + /**** + LOGOUT - + *****/ + if (request.nextUrl.pathname.startsWith('/logout')) { + console.log('Running Logout middleware'); + const origin = getOrigin(request); + return await logoutFn(request, origin); + } + /**** + END OF LOGOUT + *****/ + /**** + Account + *****/ + + if (request.nextUrl.pathname.startsWith('/account')) { + console.log('Running Account middleware'); + //const newHeaders = new Headers(request.headers) + const origin = getOrigin(request); + //console.log ("origin", origin) + return await isLoggedIn(request, origin); + } + + /**** + END OF Account + *****/ +} + +export const config = { + matcher: ['/authorize', '/logout', '/account'] +};