mirror of
https://github.com/vercel/commerce.git
synced 2025-05-12 12:47:50 +00:00
487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
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
|
|
});
|
|
}
|