import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; export const CUSTOMER_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL!; export const CUSTOMER_API_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || ''; export const ORIGIN_URL = process.env.SHOPIFY_ORIGIN_URL || ''; export const USER_AGENT = '*'; 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) { const [header, payload, signature] = token.split('.'); const decodedHeader = JSON.parse(atob(header || '')); const decodedPayload = JSON.parse(atob(payload || '')); return { header: decodedHeader, payload: decodedPayload, signature }; } 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}/api/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 }); if (!response.ok) { const error = await response.text(); console.log('data response error auth', error); console.log('response auth', response.status); return { success: false, message: `Response error auth` }; } const data = await response.json(); 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 nonceValue = nonce.payload.nonce; const shopNonce = request.cookies.get('shop_nonce'); const shopNonceValue = shopNonce?.value; if (nonceValue !== shopNonceValue) { 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 clientId = CUSTOMER_API_CLIENT_ID; 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': USER_AGENT, Origin: origin }; const tokenRequestUrl = `${CUSTOMER_API_URL}/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, CUSTOMER_API_URL, 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: NextResponse) { //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: NextResponse; 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; } 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}`); console.log('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 = ORIGIN_URL; } else { newOrigin = nextOrigin; } console.log('New Origin', newOrigin); return newOrigin; } export async function authorize(request: NextRequest, origin: string) { const clientId = CUSTOMER_API_CLIENT_ID; const newHeaders = new Headers(request.headers); /*** STEP 1: Get the initial access token or deny access ****/ const dataInitialToken = await initialAccessToken(request, origin, CUSTOMER_API_URL, clientId); console.log('data initial token', dataInitialToken); if (!dataInitialToken.success) { console.log('Error: Access Denied. Check logs', dataInitialToken.message); newHeaders.set('x-shop-access', 'denied'); return NextResponse.json({ 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, CUSTOMER_API_URL, origin || '' ); console.log('customer access token', customerAccessToken); if (!customerAccessToken.success) { console.log('Error: Customer Access Token'); newHeaders.set('x-shop-access', 'denied'); return NextResponse.json({ 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() + ''; console.log('expires at', expiresAt); return await createAllCookies({ response: authResponse, customerAccessToken: customerAccessToken?.data?.access_token, expires_in, refresh_token, expiresAt, id_token }); }