← back to Dw Signup Fulfillment

lib/shopify-oauth.js

84 lines

'use strict';

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const config = require('./config');

const SCOPES = [
  'read_customers', 'write_customers',
  'read_orders', 'write_orders',
  'read_discounts', 'write_discounts',
  'read_gift_cards', 'write_gift_cards',
  'read_gift_card_transactions', 'write_gift_card_transactions',
];

function secret() { return config.SHOPIFY_SIGNUP_APP_CLIENT_SECRET; }
function digest(value) { return crypto.createHmac('sha256', secret()).update(value).digest('hex'); }
function safeEqual(a, b) {
  const aa = Buffer.from(String(a || '')), bb = Buffer.from(String(b || ''));
  return aa.length === bb.length && crypto.timingSafeEqual(aa, bb);
}
function makeState() {
  const payload = `${Date.now()}.${crypto.randomBytes(18).toString('hex')}`;
  return `${payload}.${digest(payload)}`;
}
function validState(state) {
  const parts = String(state || '').split('.');
  if (parts.length !== 3 || !secret()) return false;
  const payload = `${parts[0]}.${parts[1]}`;
  return safeEqual(parts[2], digest(payload)) && Date.now() - Number(parts[0]) < 10 * 60 * 1000;
}
function validShop(shop) {
  return /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i.test(String(shop || '')) && shop === config.SHOP_DOMAIN;
}
function authorizeUrl() {
  const state = makeState();
  const publicUrl = config.PUBLIC_URL || 'https://signup.designerwallcoverings.com';
  const q = new URLSearchParams({
    client_id: config.SHOPIFY_APP_CLIENT_ID,
    scope: SCOPES.join(','),
    redirect_uri: `${publicUrl}/oauth/callback`,
    state,
  });
  return `https://${config.SHOP_DOMAIN}/admin/oauth/authorize?${q}`;
}
function canonicalQuery(query) {
  return Object.keys(query).filter(k => k !== 'hmac' && k !== 'signature').sort()
    .map(k => `${k}=${Array.isArray(query[k]) ? query[k].join(',') : query[k]}`).join('&');
}
function validCallback(query) {
  if (!secret() || !validShop(query.shop) || !validState(query.state)) return false;
  return safeEqual(query.hmac, digest(canonicalQuery(query)));
}
async function exchangeCode(query) {
  const response = await fetch(`https://${query.shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', accept: 'application/json' },
    body: JSON.stringify({
      client_id: config.SHOPIFY_APP_CLIENT_ID,
      client_secret: secret(),
      code: query.code,
    }),
  });
  const body = await response.json().catch(() => ({}));
  if (!response.ok || !body.access_token) throw new Error(`token_exchange_failed_${response.status}`);
  return { token: body.access_token, scope: body.scope || '' };
}
function persistToken(token) {
  const envPath = path.join(__dirname, '..', '.env');
  let source = '';
  try { source = fs.readFileSync(envPath, 'utf8'); } catch {}
  const line = `SHOPIFY_FULFILLMENT_TOKEN=${token}`;
  source = /^SHOPIFY_FULFILLMENT_TOKEN=.*$/m.test(source)
    ? source.replace(/^SHOPIFY_FULFILLMENT_TOKEN=.*$/m, line)
    : `${source.replace(/\s*$/, '')}\n${line}\n`;
  const temp = `${envPath}.${process.pid}.tmp`;
  fs.writeFileSync(temp, source, { mode: 0o600 });
  fs.renameSync(temp, envPath);
  fs.chmodSync(envPath, 0o600);
  config.SHOPIFY_FULFILLMENT_TOKEN = token;
}

module.exports = { SCOPES, authorizeUrl, validCallback, exchangeCode, persistToken };