← 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 };